Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 41 additions & 31 deletions src/Mappers/Root/EnumTypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use TheCodingMachine\GraphQLite\Discovery\ClassFinder;
use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory;
use TheCodingMachine\GraphQLite\Types\EnumType;
use TheCodingMachine\GraphQLite\Types\ExposedEnumCase;
use TheCodingMachine\GraphQLite\Utils\DescriptionResolver;
use UnitEnum;

Expand Down Expand Up @@ -138,64 +139,73 @@ private function mapByClassName(string $enumClass): EnumType|null
: null,
);

/** @var array<string, string|null> $enumCaseDescriptions */
$enumCaseDescriptions = [];
/** @var array<string, string> $enumCaseDeprecationReasons */
$enumCaseDeprecationReasons = [];
$hasEnumValueAttribute = false;
// Single pass: build an ExposedEnumCase for every case, resolving its metadata, and bucket
// it by whether it carries #[EnumValue]. The moment any case is annotated the enum is in
// opt-in mode and only the annotated bucket is exposed; otherwise every case is exposed.
/** @var list<ExposedEnumCase> $annotatedCases */
$annotatedCases = [];
/** @var list<ExposedEnumCase> $unannotatedCases */
$unannotatedCases = [];

foreach ($reflectionEnum->getCases() as $reflectionEnumCase) {
$attribute = $this->annotationReader->getEnumValueAnnotation($reflectionEnumCase);
$docBlock = $this->docBlockFactory->create($reflectionEnumCase);
$enumValueAttribute = $this->annotationReader->getEnumValueAnnotation($reflectionEnumCase);

if ($enumValueAttribute !== null) {
$hasEnumValueAttribute = true;
}

$enumCaseDescriptions[$reflectionEnumCase->getName()] = $this->descriptionResolver->resolve(
$enumValueAttribute?->description,
$description = $this->descriptionResolver->resolve(
$attribute?->description,
$docBlock->getSummary() ?: null,
);

$explicitDeprecation = $enumValueAttribute?->deprecationReason;
$deprecationReason = null;
$explicitDeprecation = $attribute?->deprecationReason;
if ($explicitDeprecation !== null) {
// Explicit `deprecationReason` always wins; an empty string deliberately clears
// any @deprecated tag on the case docblock the same way an empty description
// blocks the docblock fallback.
if ($explicitDeprecation !== '') {
$enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = $explicitDeprecation;
$deprecationReason = $explicitDeprecation;
}
} else {
$deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null;
if ($deprecation) {
$deprecationReason = (string) $deprecation;
}
continue;
}

$deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null;
$exposedCase = new ExposedEnumCase($reflectionEnumCase->getValue(), $description, $deprecationReason);

// phpcs:ignore
if ($deprecation) {
$enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = (string) $deprecation;
if ($attribute !== null) {
$annotatedCases[] = $exposedCase;
} else {
$unannotatedCases[] = $exposedCase;
}
}

if (! $hasEnumValueAttribute) {
$exposedCases = $annotatedCases !== [] ? $annotatedCases : $unannotatedCases;

if ($annotatedCases === []) {
$this->warnEnumHasNoEnumValueAttribute($enumClass);
}

$type = new EnumType($enumClass, $typeName, $enumDescription, $enumCaseDescriptions, $enumCaseDeprecationReasons, $useValues);
$type = new EnumType($exposedCases, $typeName, $enumDescription, $useValues);

return $this->cacheByName[$type->name] = $this->cacheByClass[$enumClass] = $type;
}

/**
* Emits a deprecation notice when a GraphQL-mapped enum declares zero {@see EnumValue}
* attributes across its cases — the signal that the developer has not yet engaged with
* the opt-in model that a future major release will require.
* attributes across its cases — the signal that the developer has not yet engaged with the
* per-case opt-in model.
*
* `#[EnumValue]` is now the per-case exposure toggle. As soon as an enum carries the attribute
* on at least one case it enters opt-in mode: only the annotated cases are exposed and every
* unannotated case is hidden from the schema (mirroring `#[Field]`'s opt-in model on classes).
* A fully-unannotated enum stays in legacy mode — every case is still exposed — and this notice
* fires to flag that the enum has not opted in, so a future major release that makes the
* attribute mandatory would otherwise hide all of its cases.
*
* Today every case is automatically exposed in the schema regardless of `#[EnumValue]` —
* this call site keeps that behaviour intact. The notice announces the planned migration:
* a future major release will require `#[EnumValue]` on each case that should participate
* in the schema, and unannotated cases will be hidden (mirroring `#[Field]`'s opt-in
* model on classes). Partial annotation is deliberately allowed and intentionally silent
* so that leaving some cases unannotated can be used to hide them once the default flips.
* Partial annotation is deliberately silent: leaving a case unannotated is now the supported
* mechanism for keeping it out of the public schema, so it must not itself produce an advisory.
*
* @param class-string<UnitEnum> $enumClass
*/
Expand All @@ -204,8 +214,8 @@ private function warnEnumHasNoEnumValueAttribute(string $enumClass): void
trigger_error(
sprintf(
'Enum "%s" is mapped to a GraphQL enum type but declares no #[EnumValue] attributes on any case. '
. 'Today every case is automatically exposed; a future major release will require #[EnumValue] on each case that should participate in the schema, and unannotated cases will be hidden (mirroring #[Field]\'s opt-in model on classes). '
. 'Add #[EnumValue] to every case you want to keep exposed. Omit it only from cases you want hidden from the public schema after the future default flip.',
. 'Every case is exposed in legacy mode; adding #[EnumValue] to any case switches the enum to opt-in mode, where only annotated cases are exposed and unannotated ones are hidden (mirroring #[Field]\'s opt-in model on classes). '
. 'Add #[EnumValue] to every case you want exposed. Omit it only from cases you want hidden from the public schema.',
$enumClass,
),
E_USER_DEPRECATED,
Expand Down
22 changes: 11 additions & 11 deletions src/Types/EnumType.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,30 @@

/**
* An extension of the EnumType to support native enums.
*
* @internal The constructor shape (an explicit list of exposed cases with resolved metadata) is a
* framework-internal contract with EnumTypeMapper, not a public API.
*/
class EnumType extends BaseEnumType
{
/**
* @param class-string<UnitEnum> $enumName
* @param array<string, string|null> $caseDescriptions
* @param array<string, string> $caseDeprecationReasons
* @param list<ExposedEnumCase> $cases The enum cases that participate in the schema, each
* paired with its resolved metadata.
*/
public function __construct(
string $enumName,
array $cases,
string $typeName,
string|null $description,
array $caseDescriptions,
array $caseDeprecationReasons,
private readonly bool $useValues = false,
) {
$typeValues = [];
foreach ($enumName::cases() as $case) {
$key = $this->serialize($case);
foreach ($cases as $exposed) {
$key = $this->serialize($exposed->case);
$typeValues[$key] = [
'name' => $key,
'value' => $case,
'description' => $caseDescriptions[$case->name] ?? null,
'deprecationReason' => $caseDeprecationReasons[$case->name] ?? null,
'value' => $exposed->case,
'description' => $exposed->description,
'deprecationReason' => $exposed->deprecationReason,
];
}

Expand Down
22 changes: 22 additions & 0 deletions src/Types/ExposedEnumCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Types;

use UnitEnum;

/**
* A single enum case exposed in the GraphQL schema, paired with its resolved metadata.
*
* @internal Built by EnumTypeMapper and consumed by EnumType; not part of the public API.
*/
final class ExposedEnumCase
{
public function __construct(
public readonly UnitEnum $case,
public readonly string|null $description,
public readonly string|null $deprecationReason,
) {
}
}
16 changes: 16 additions & 0 deletions tests/Fixtures/EnumExposure/EnumExposureController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposure;

use TheCodingMachine\GraphQLite\Annotations\Query;

class EnumExposureController
{
#[Query]
public function publishStatus(): PublishStatus
{
return PublishStatus::Published;
}
}
28 changes: 28 additions & 0 deletions tests/Fixtures/EnumExposure/PublishStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposure;

use TheCodingMachine\GraphQLite\Annotations\EnumValue;
use TheCodingMachine\GraphQLite\Annotations\Type;

/**
* Partially-annotated enum: it carries #[EnumValue] on some cases, which puts it in opt-in mode.
* Only the annotated cases must reach the schema; the unannotated internal cases stay hidden.
*/
#[Type]
enum PublishStatus: string
{
#[EnumValue(description: 'Visible to everyone.')]
case Published = 'published';

#[EnumValue]
case Scheduled = 'scheduled';

// Internal-only working state — deliberately left unannotated so it never enters the schema.
case Draft = 'draft';

// Internal-only terminal state — likewise hidden.
case Archived = 'archived';
}
22 changes: 22 additions & 0 deletions tests/Fixtures/EnumExposureLegacy/Weekday.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposureLegacy;

use TheCodingMachine\GraphQLite\Annotations\Type;

/**
* Fully-unannotated enum: it declares zero #[EnumValue] attributes, so it stays in legacy mode
* where every case is exposed and case descriptions still fall back to the docblock summary.
*/
#[Type]
enum Weekday: string
{
/**
* The first working day of the week.
*/
case Monday = 'monday';
case Tuesday = 'tuesday';
case Wednesday = 'wednesday';
}
16 changes: 16 additions & 0 deletions tests/Fixtures/EnumExposureLegacy/WeekdayController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposureLegacy;

use TheCodingMachine\GraphQLite\Annotations\Query;

class WeekdayController
{
#[Query]
public function weekday(): Weekday
{
return Weekday::Monday;
}
}
33 changes: 17 additions & 16 deletions tests/Integration/DescriptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,11 @@ public function testEnumValueAttributeProvidesCaseDescription(): void

public function testEnumWithZeroEnumValueAttributesTriggersDeprecation(): void
{
// The Era fixture deliberately declares zero #[EnumValue] attributes — the signal that
// the developer has not yet engaged with the opt-in migration. That is the scenario the
// The Era fixture deliberately declares zero #[EnumValue] attributes, so it stays in
// legacy mode (every case exposed) and the advisory fires. That is the scenario the
// advisory targets; partial annotation on other enums (like Genre in the Description
// namespace) is deliberately silent because it already acknowledges the new model.
$this->expectUserDeprecationMessageMatches('/declares no #\[EnumValue\] attributes.*future major/s');
// namespace) is deliberately silent because it has already opted in.
$this->expectUserDeprecationMessageMatches('/declares no #\[EnumValue\] attributes.*legacy mode/s');

$schema = $this->buildSchema(Era::class);
// Force enum resolution — types are lazy-mapped until referenced.
Expand Down Expand Up @@ -149,18 +149,19 @@ static function (int $errno, string $errstr) use (&$captured): bool {
$this->assertSame([], $captured, 'Partial #[EnumValue] annotation must not trigger the advisory notice.');
}

public function testEnumCaseWithoutAttributeFallsBackToDocblock(): void
public function testUnannotatedCaseIsHiddenInOptInMode(): void
{
$schema = $this->buildSchema(Book::class);

$genreType = $schema->getType('Genre');
$nonFictionValue = $genreType->getValue('NonFiction');
// The NonFiction case has no #[EnumValue] attribute, so its description comes from the docblock.
$this->assertNotNull($nonFictionValue->description);
$this->assertStringContainsString(
'This docblock description should appear on the NonFiction enum value',
$nonFictionValue->description,
);

// Genre carries #[EnumValue] on Fiction and Poetry, which puts it in opt-in mode. The
// NonFiction case has no #[EnumValue] attribute, so it is now hidden from the schema
// entirely rather than falling back to its docblock description.
$this->assertNull($genreType->getValue('NonFiction'));

$exposedNames = array_map(static fn ($value) => $value->name, $genreType->getValues());
$this->assertSame(['Fiction', 'Poetry'], $exposedNames);
}

public function testEnumValueAttributeProvidesDeprecationReason(): void
Expand All @@ -172,20 +173,20 @@ public function testEnumValueAttributeProvidesDeprecationReason(): void
$this->assertSame('Use Fiction::Verse instead.', $poetryValue->deprecationReason);
}

public function testDisablingDocblockFallbackSuppressesEnumCaseDescription(): void
public function testDisablingDocblockFallbackKeepsExplicitDescriptionOnExposedCase(): void
{
$schema = $this->buildSchema(Book::class, docblockDescriptions: false);

$genreType = $schema->getType('Genre');

// Fiction has an explicit #[EnumValue] description — still present.
// Fiction has an explicit #[EnumValue] description — still present with the toggle off.
$this->assertSame(
'Fiction works including novels and short stories.',
$genreType->getValue('Fiction')->description,
);

// NonFiction relied on its docblock summary — with the toggle off, it must disappear.
$this->assertNull($genreType->getValue('NonFiction')->description);
// NonFiction is unannotated, so opt-in mode hides it regardless of the docblock toggle.
$this->assertNull($genreType->getValue('NonFiction'));
}

public function testExtendTypeSuppliesDescriptionWhenBaseTypeHasNone(): void
Expand Down
Loading
Loading