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..217ce61b1c5 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -17,6 +17,8 @@ use Statamic\Facades\Search; use Statamic\Facades\Site; use Statamic\Facades\User; +use Statamic\GraphQL\Types\DynamicEntryUnionType; +use Statamic\GraphQL\Types\EntryInterface; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntries; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntry as EntryResource; use Statamic\Query\OrderBy; @@ -455,10 +457,32 @@ 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; + } + + $type = DynamicEntryUnionType::createTypeFor($this->getConfiguredCollections()); 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..6258087cf71 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -19,6 +19,7 @@ use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; use Statamic\Facades\User; +use Statamic\GraphQL\Types\DynamicTermUnionType; use Statamic\GraphQL\Types\TermInterface; use Statamic\Http\Resources\CP\Taxonomies\TermsFieldtypeTerms as TermsResource; use Statamic\Query\OrderBy; @@ -529,10 +530,31 @@ 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; + } + + $type = DynamicTermUnionType::createTypeFor($this->getConfiguredTaxonomies()); 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..4c80389e223 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,10 @@ use Statamic\GraphQL\Queries\NavsQuery; 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; use Statamic\GraphQL\Queries\TermQuery; @@ -69,12 +75,86 @@ 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)) + ->flatMap(fn ($handle) => [ + new SpecificEntriesQuery($handle), + new SpecificEntryQuery($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)) + ->flatMap(fn ($handle) => [ + new SpecificTermsQuery($handle), + new SpecificTermQuery($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..3746111facf --- /dev/null +++ b/src/GraphQL/Queries/SpecificEntriesQuery.php @@ -0,0 +1,117 @@ +attributes['name'] = Str::camel($collectionHandle); + + parent::__construct(); + } + + public function type(): Type + { + $collection = Collection::findByHandle($this->collectionHandle); + + return GraphQL::nonNull(GraphQL::paginate(DynamicEntryUnionType::createTypeFor($collection))); + } + + 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/SpecificEntryQuery.php b/src/GraphQL/Queries/SpecificEntryQuery.php new file mode 100644 index 00000000000..2998048fc9d --- /dev/null +++ b/src/GraphQL/Queries/SpecificEntryQuery.php @@ -0,0 +1,111 @@ +attributes['name'] = $name; + + parent::__construct(); + } + + public function type(): Type + { + return DynamicEntryUnionType::createTypeFor(Collection::findByHandle($this->collectionHandle)); + } + + 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(); + + 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..13a6dd97c0b --- /dev/null +++ b/src/GraphQL/Queries/SpecificTermQuery.php @@ -0,0 +1,66 @@ +attributes['name'] = $name; + + parent::__construct(); + } + + public function type(): Type + { + return DynamicTermUnionType::createTypeFor(Taxonomy::findByHandle($this->taxonomyHandle)); + } + + 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/src/GraphQL/Queries/SpecificTermsQuery.php b/src/GraphQL/Queries/SpecificTermsQuery.php new file mode 100644 index 00000000000..88273e7ebbe --- /dev/null +++ b/src/GraphQL/Queries/SpecificTermsQuery.php @@ -0,0 +1,90 @@ +attributes['name'] = Str::camel($taxonomyHandle); + + parent::__construct(); + } + + public function type(): Type + { + return GraphQL::nonNull(GraphQL::paginate(DynamicTermUnionType::createTypeFor(Taxonomy::findByHandle($this->taxonomyHandle)))); + } + + 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..f37764a8af1 --- /dev/null +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -0,0 +1,116 @@ + '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 static function createTypeFor(Collection|array $collections): Type + { + $combinations = is_array($collections) + ? 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'])); + } + + $unionType = new static($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + + 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())); + } + + 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 new file mode 100644 index 00000000000..6e97ce95586 --- /dev/null +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -0,0 +1,101 @@ + '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 static function createTypeFor(Taxonomy|array $taxonomies): Type + { + $combinations = is_array($taxonomies) + ? 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'])); + } + + $unionType = new static($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + + 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())); + } + + 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/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..dede7813def --- /dev/null +++ b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php @@ -0,0 +1,201 @@ +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 || $q instanceof SpecificEntryQuery)->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 || $q instanceof SpecificEntryQuery) + ->values(); + + $this->assertCount(2, $collectionQueries); + $this->assertEquals(['blog', 'blogEntry'], $collectionQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all()); + } + + #[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 || $q instanceof SpecificEntryQuery) + ->values(); + + $names = $collectionQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); + + $this->assertCount(4, $collectionQueries); + $this->assertEquals(['blog', 'blogEntry', 'page', '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 || $q instanceof SpecificEntryQuery)->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 || $q instanceof SpecificTermQuery)->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 || $q instanceof SpecificTermQuery) + ->values(); + + $this->assertCount(2, $termQueries); + $this->assertEquals(['tag', 'tags'], $termQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all()); + } + + #[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 || $q instanceof SpecificTermQuery) + ->values(); + + $names = $termQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); + + $this->assertCount(4, $termQueries); + $this->assertEquals(['categories', 'category', 'tag', '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 || $q instanceof SpecificTermQuery)->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/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 new file mode 100644 index 00000000000..2ac5c04cff4 --- /dev/null +++ b/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php @@ -0,0 +1,105 @@ +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])); + + EntryInterface::addTypes(); + + $expected = EntryType::buildName(Collection::findByHandle('blog_posts'), $article); + + $type = $this->fieldtype([ + 'collections' => ['blog_posts'], + 'max_items' => 1, + ])->toGqlType(); + + $this->assertEquals($expected, $type->name); + } + + #[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/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']; + } +} 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..1dd938c9910 --- /dev/null +++ b/tests/GraphQL/TermsFieldtypeGqlTypeTest.php @@ -0,0 +1,109 @@ +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])); + + TermInterface::addTypes(); + + $expected = TermType::buildName($taxonomy, $tag); + + $type = $this->fieldtype([ + 'taxonomies' => ['tags'], + 'max_items' => 1, + ])->toGqlType(); + + $this->assertEquals($expected, $type->name); + } + + #[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); + } +}