Skip to content

Commit 42b0fd0

Browse files
committed
feat!: make #[EnumValue] control per-case enum schema exposure
Once any case of a #[Type]-mapped enum carries #[EnumValue], the enum enters opt-in mode: only annotated cases are exposed and unannotated cases are hidden from the schema. A fully-unannotated mapped enum stays in legacy mode (every case exposed) and emits the existing deprecation advisory. Adds Types\ExposedEnumCase and reshapes EnumType's internal constructor to take the resolved exposed cases the mapper decided, replacing the parallel per-name metadata arrays. BREAKING CHANGE: partially-annotated #[Type] enums now hide their unannotated cases from the schema. Annotate every case you want exposed.
1 parent 8e84377 commit 42b0fd0

12 files changed

Lines changed: 323 additions & 70 deletions

File tree

src/Mappers/Root/EnumTypeMapper.php

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use TheCodingMachine\GraphQLite\Discovery\ClassFinder;
2222
use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory;
2323
use TheCodingMachine\GraphQLite\Types\EnumType;
24+
use TheCodingMachine\GraphQLite\Types\ExposedEnumCase;
2425
use TheCodingMachine\GraphQLite\Utils\DescriptionResolver;
2526
use UnitEnum;
2627

@@ -138,64 +139,73 @@ private function mapByClassName(string $enumClass): EnumType|null
138139
: null,
139140
);
140141

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

147150
foreach ($reflectionEnum->getCases() as $reflectionEnumCase) {
151+
$attribute = $this->annotationReader->getEnumValueAnnotation($reflectionEnumCase);
148152
$docBlock = $this->docBlockFactory->create($reflectionEnumCase);
149-
$enumValueAttribute = $this->annotationReader->getEnumValueAnnotation($reflectionEnumCase);
150153

151-
if ($enumValueAttribute !== null) {
152-
$hasEnumValueAttribute = true;
153-
}
154-
155-
$enumCaseDescriptions[$reflectionEnumCase->getName()] = $this->descriptionResolver->resolve(
156-
$enumValueAttribute?->description,
154+
$description = $this->descriptionResolver->resolve(
155+
$attribute?->description,
157156
$docBlock->getSummary() ?: null,
158157
);
159158

160-
$explicitDeprecation = $enumValueAttribute?->deprecationReason;
159+
$deprecationReason = null;
160+
$explicitDeprecation = $attribute?->deprecationReason;
161161
if ($explicitDeprecation !== null) {
162162
// Explicit `deprecationReason` always wins; an empty string deliberately clears
163163
// any @deprecated tag on the case docblock the same way an empty description
164164
// blocks the docblock fallback.
165165
if ($explicitDeprecation !== '') {
166-
$enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = $explicitDeprecation;
166+
$deprecationReason = $explicitDeprecation;
167+
}
168+
} else {
169+
$deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null;
170+
if ($deprecation) {
171+
$deprecationReason = (string) $deprecation;
167172
}
168-
continue;
169173
}
170174

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

173-
// phpcs:ignore
174-
if ($deprecation) {
175-
$enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = (string) $deprecation;
177+
if ($attribute !== null) {
178+
$annotatedCases[] = $exposedCase;
179+
} else {
180+
$unannotatedCases[] = $exposedCase;
176181
}
177182
}
178183

179-
if (! $hasEnumValueAttribute) {
184+
$exposedCases = $annotatedCases !== [] ? $annotatedCases : $unannotatedCases;
185+
186+
if ($annotatedCases === []) {
180187
$this->warnEnumHasNoEnumValueAttribute($enumClass);
181188
}
182189

183-
$type = new EnumType($enumClass, $typeName, $enumDescription, $enumCaseDescriptions, $enumCaseDeprecationReasons, $useValues);
190+
$type = new EnumType($exposedCases, $typeName, $enumDescription, $useValues);
184191

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

188195
/**
189196
* Emits a deprecation notice when a GraphQL-mapped enum declares zero {@see EnumValue}
190-
* attributes across its cases — the signal that the developer has not yet engaged with
191-
* the opt-in model that a future major release will require.
197+
* attributes across its cases — the signal that the developer has not yet engaged with the
198+
* per-case opt-in model.
199+
*
200+
* `#[EnumValue]` is now the per-case exposure toggle. As soon as an enum carries the attribute
201+
* on at least one case it enters opt-in mode: only the annotated cases are exposed and every
202+
* unannotated case is hidden from the schema (mirroring `#[Field]`'s opt-in model on classes).
203+
* A fully-unannotated enum stays in legacy mode — every case is still exposed — and this notice
204+
* fires to flag that the enum has not opted in, so a future major release that makes the
205+
* attribute mandatory would otherwise hide all of its cases.
192206
*
193-
* Today every case is automatically exposed in the schema regardless of `#[EnumValue]` —
194-
* this call site keeps that behaviour intact. The notice announces the planned migration:
195-
* a future major release will require `#[EnumValue]` on each case that should participate
196-
* in the schema, and unannotated cases will be hidden (mirroring `#[Field]`'s opt-in
197-
* model on classes). Partial annotation is deliberately allowed and intentionally silent
198-
* so that leaving some cases unannotated can be used to hide them once the default flips.
207+
* Partial annotation is deliberately silent: leaving a case unannotated is now the supported
208+
* mechanism for keeping it out of the public schema, so it must not itself produce an advisory.
199209
*
200210
* @param class-string<UnitEnum> $enumClass
201211
*/
@@ -204,8 +214,8 @@ private function warnEnumHasNoEnumValueAttribute(string $enumClass): void
204214
trigger_error(
205215
sprintf(
206216
'Enum "%s" is mapped to a GraphQL enum type but declares no #[EnumValue] attributes on any case. '
207-
. '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). '
208-
. '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.',
217+
. '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). '
218+
. 'Add #[EnumValue] to every case you want exposed. Omit it only from cases you want hidden from the public schema.',
209219
$enumClass,
210220
),
211221
E_USER_DEPRECATED,

src/Types/EnumType.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,30 @@
1414

1515
/**
1616
* An extension of the EnumType to support native enums.
17+
*
18+
* @internal The constructor shape (an explicit list of exposed cases with resolved metadata) is a
19+
* framework-internal contract with EnumTypeMapper, not a public API.
1720
*/
1821
class EnumType extends BaseEnumType
1922
{
2023
/**
21-
* @param class-string<UnitEnum> $enumName
22-
* @param array<string, string|null> $caseDescriptions
23-
* @param array<string, string> $caseDeprecationReasons
24+
* @param list<ExposedEnumCase> $cases The enum cases that participate in the schema, each
25+
* paired with its resolved metadata.
2426
*/
2527
public function __construct(
26-
string $enumName,
28+
array $cases,
2729
string $typeName,
2830
string|null $description,
29-
array $caseDescriptions,
30-
array $caseDeprecationReasons,
3131
private readonly bool $useValues = false,
3232
) {
3333
$typeValues = [];
34-
foreach ($enumName::cases() as $case) {
35-
$key = $this->serialize($case);
34+
foreach ($cases as $exposed) {
35+
$key = $this->serialize($exposed->case);
3636
$typeValues[$key] = [
3737
'name' => $key,
38-
'value' => $case,
39-
'description' => $caseDescriptions[$case->name] ?? null,
40-
'deprecationReason' => $caseDeprecationReasons[$case->name] ?? null,
38+
'value' => $exposed->case,
39+
'description' => $exposed->description,
40+
'deprecationReason' => $exposed->deprecationReason,
4141
];
4242
}
4343

src/Types/ExposedEnumCase.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Types;
6+
7+
use UnitEnum;
8+
9+
/**
10+
* A single enum case exposed in the GraphQL schema, paired with its resolved metadata.
11+
*
12+
* @internal Built by EnumTypeMapper and consumed by EnumType; not part of the public API.
13+
*/
14+
final class ExposedEnumCase
15+
{
16+
public function __construct(
17+
public readonly UnitEnum $case,
18+
public readonly string|null $description,
19+
public readonly string|null $deprecationReason,
20+
) {
21+
}
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposure;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\Query;
8+
9+
class EnumExposureController
10+
{
11+
#[Query]
12+
public function publishStatus(): PublishStatus
13+
{
14+
return PublishStatus::Published;
15+
}
16+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposure;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
8+
use TheCodingMachine\GraphQLite\Annotations\Type;
9+
10+
/**
11+
* Partially-annotated enum: it carries #[EnumValue] on some cases, which puts it in opt-in mode.
12+
* Only the annotated cases must reach the schema; the unannotated internal cases stay hidden.
13+
*/
14+
#[Type]
15+
enum PublishStatus: string
16+
{
17+
#[EnumValue(description: 'Visible to everyone.')]
18+
case Published = 'published';
19+
20+
#[EnumValue]
21+
case Scheduled = 'scheduled';
22+
23+
// Internal-only working state — deliberately left unannotated so it never enters the schema.
24+
case Draft = 'draft';
25+
26+
// Internal-only terminal state — likewise hidden.
27+
case Archived = 'archived';
28+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposureLegacy;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\Type;
8+
9+
/**
10+
* Fully-unannotated enum: it declares zero #[EnumValue] attributes, so it stays in legacy mode
11+
* where every case is exposed and case descriptions still fall back to the docblock summary.
12+
*/
13+
#[Type]
14+
enum Weekday: string
15+
{
16+
/**
17+
* The first working day of the week.
18+
*/
19+
case Monday = 'monday';
20+
case Tuesday = 'tuesday';
21+
case Wednesday = 'wednesday';
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\EnumExposureLegacy;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\Query;
8+
9+
class WeekdayController
10+
{
11+
#[Query]
12+
public function weekday(): Weekday
13+
{
14+
return Weekday::Monday;
15+
}
16+
}

tests/Integration/DescriptionTest.php

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ public function testEnumValueAttributeProvidesCaseDescription(): void
108108

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

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

152-
public function testEnumCaseWithoutAttributeFallsBackToDocblock(): void
152+
public function testUnannotatedCaseIsHiddenInOptInMode(): void
153153
{
154154
$schema = $this->buildSchema(Book::class);
155155

156156
$genreType = $schema->getType('Genre');
157-
$nonFictionValue = $genreType->getValue('NonFiction');
158-
// The NonFiction case has no #[EnumValue] attribute, so its description comes from the docblock.
159-
$this->assertNotNull($nonFictionValue->description);
160-
$this->assertStringContainsString(
161-
'This docblock description should appear on the NonFiction enum value',
162-
$nonFictionValue->description,
163-
);
157+
158+
// Genre carries #[EnumValue] on Fiction and Poetry, which puts it in opt-in mode. The
159+
// NonFiction case has no #[EnumValue] attribute, so it is now hidden from the schema
160+
// entirely rather than falling back to its docblock description.
161+
$this->assertNull($genreType->getValue('NonFiction'));
162+
163+
$exposedNames = array_map(static fn ($value) => $value->name, $genreType->getValues());
164+
$this->assertSame(['Fiction', 'Poetry'], $exposedNames);
164165
}
165166

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

175-
public function testDisablingDocblockFallbackSuppressesEnumCaseDescription(): void
176+
public function testDisablingDocblockFallbackKeepsExplicitDescriptionOnExposedCase(): void
176177
{
177178
$schema = $this->buildSchema(Book::class, docblockDescriptions: false);
178179

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

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

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

191192
public function testExtendTypeSuppliesDescriptionWhenBaseTypeHasNone(): void

0 commit comments

Comments
 (0)