diff --git a/config/graphql.php b/config/graphql.php index 329c0d463f0..30fe3dd14c4 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -27,6 +27,33 @@ '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. + | + | 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', + // '*', + ], + ], + /* |-------------------------------------------------------------------------- | Authentication diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 3a419e3503e..6011c5f66e4 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.enabled', 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/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 81e1971e3ee..67d2d66b825 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,55 @@ protected function getConfiguredCollections() public function toGqlType() { - $type = GraphQL::type('EntryInterface'); + // Fallback to old behaviour if improved types are disabled. + if (! config('statamic.graphql.improved_types.enabled', 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); + + 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..a0c99307b2c 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,58 @@ protected function getConfiguredTaxonomies() public function toGqlType() { - $type = GraphQL::type(TermInterface::NAME); + if (! config('statamic.graphql.improved_types.enabled', 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); + + 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/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/src/GraphQL/Types/DynamicEntryUnionType.php b/src/GraphQL/Types/DynamicEntryUnionType.php new file mode 100644 index 00000000000..ac64138b774 --- /dev/null +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -0,0 +1,41 @@ + 'DynamicEntryUnionType', + ]; + + public function __construct(protected array $types) + { + $this->attributes['name'] = self::getTypeName($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..f40413545b9 --- /dev/null +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -0,0 +1,41 @@ + 'DynamicTermUnionType', + ]; + + public function __construct(protected array $types) + { + $this->attributes['name'] = self::getTypeName($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/AssetsFieldtypeGqlTypeTest.php b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php new file mode 100644 index 00000000000..7075f257832 --- /dev/null +++ b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php @@ -0,0 +1,106 @@ +set('statamic.graphql.improved_types.enabled', 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.enabled', 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.enabled', 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); + } +} diff --git a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php new file mode 100644 index 00000000000..d726be038ef --- /dev/null +++ b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php @@ -0,0 +1,199 @@ +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 + { + // 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']; + } + + 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/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/SpecificEntriesQueryTest.php b/tests/GraphQL/SpecificEntriesQueryTest.php new file mode 100644 index 00000000000..4ae35ea0154 --- /dev/null +++ b/tests/GraphQL/SpecificEntriesQueryTest.php @@ -0,0 +1,85 @@ +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 + { + // 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 new file mode 100644 index 00000000000..80bce2571a6 --- /dev/null +++ b/tests/GraphQL/SpecificTermsQueryTest.php @@ -0,0 +1,84 @@ +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 + { + // 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/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); + } +} +