Skip to content

Commit cc04b22

Browse files
committed
feat: #[EnumValue] attribute for per-case enum metadata
Closes the deeper portion of #740 that the original PR left as future work: native PHP 8.1+ enum cases can now carry an explicit GraphQL description and deprecation reason without relying on docblock parsing. ```php #[Type] enum Genre: string { #[EnumValue(description: 'Fiction works including novels and short stories.')] case Fiction = 'fiction'; #[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')] case Poetry = 'poetry'; } ``` Naming: follows the GraphQL specification's term ("enum values", §3.5.2, `__EnumValue`, `enumValues`) rather than the PHP language term "case". This matches every other graphqlite attribute (`Type`, `Field`, `Query`, `ExtendType`, …) which mirrors GraphQL spec names, and webonyx/graphql-php's internal `EnumValueDefinition`. Targets `Attribute::TARGET_CLASS_CONSTANT` so it applies to PHP enum cases. Precedence: explicit `description` / `deprecationReason` on the attribute win over docblock summary / `@deprecated` tag. Passing `''` deliberately suppresses the fallback at that site, matching every other `description` argument across the library. No-attribute cases continue to fall back to docblock — BC preserved. Adds `AnnotationReader::getEnumValueAnnotation(ReflectionEnumUnitCase)` helper and threads it through `EnumTypeMapper::mapByClassName()` where enum case metadata is aggregated. Tests: 5 new unit tests (attribute defaults / description / deprecation reason / both / empty string) plus 4 new integration tests over a new fixture enum (attribute-supplied description, docblock fallback, explicit deprecation, toggle-off suppresses docblock-only case). Full suite 537/537 green across cs-check, phpstan, and phpunit.
1 parent 192c78f commit cc04b22

8 files changed

Lines changed: 265 additions & 4 deletions

File tree

src/AnnotationReader.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
namespace TheCodingMachine\GraphQLite;
66

77
use ReflectionClass;
8+
use ReflectionEnumUnitCase;
89
use ReflectionMethod;
910
use ReflectionParameter;
1011
use ReflectionProperty;
1112
use TheCodingMachine\GraphQLite\Annotations\AbstractGraphQLElement;
1213
use TheCodingMachine\GraphQLite\Annotations\Decorate;
1314
use TheCodingMachine\GraphQLite\Annotations\EnumType;
15+
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
1416
use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException;
1517
use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException;
1618
use TheCodingMachine\GraphQLite\Annotations\ExtendType;
@@ -201,6 +203,24 @@ public function getEnumTypeAnnotation(ReflectionClass $refClass): EnumType|null
201203
return $this->getClassAnnotation($refClass, EnumType::class);
202204
}
203205

206+
/**
207+
* Returns the {@see EnumValue} attribute declared on a PHP enum case, or null when no
208+
* attribute is present. Callers use this to resolve the explicit description and deprecation
209+
* reason before falling back to docblock parsing.
210+
*/
211+
public function getEnumValueAnnotation(ReflectionEnumUnitCase $refCase): EnumValue|null
212+
{
213+
$attribute = $refCase->getAttributes(EnumValue::class)[0] ?? null;
214+
if ($attribute === null) {
215+
return null;
216+
}
217+
218+
$instance = $attribute->newInstance();
219+
assert($instance instanceof EnumValue);
220+
221+
return $instance;
222+
}
223+
204224
/** @param class-string<AbstractGraphQLElement> $annotationClass */
205225
public function getGraphQLElementAnnotation(ReflectionMethod $refMethod, string $annotationClass): AbstractGraphQLElement|null
206226
{

src/Annotations/EnumValue.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Annotations;
6+
7+
use Attribute;
8+
9+
/**
10+
* Attaches GraphQL metadata to an individual enum case.
11+
*
12+
* Applied to cases of a PHP 8.1+ native enum exposed as a GraphQL enum type, this attribute
13+
* provides the schema description and deprecation reason for that value without relying on
14+
* docblock parsing — mirroring the explicit {@see Type::$description} and
15+
* {@see Field::$description} pattern that the rest of the attribute system uses.
16+
*
17+
* The attribute is named after the GraphQL specification's term for an enum member ("enum
18+
* value", see §3.5.2 of the spec and the `__EnumValue` introspection type), which matches the
19+
* GraphQL-spec-mirroring naming convention of every other graphqlite attribute (`#[Type]`,
20+
* `#[Field]`, `#[Query]`, etc.). The underlying PHP language construct is `case`; the GraphQL
21+
* schema element it produces is an enum value.
22+
*
23+
* Example:
24+
* ```php
25+
* #[Type]
26+
* enum Genre: string
27+
* {
28+
* #[EnumValue(description: 'Fiction works including novels and short stories.')]
29+
* case Fiction = 'fiction';
30+
*
31+
* #[EnumValue(deprecationReason: 'Use NonFiction::Essay instead.')]
32+
* case Essay = 'essay';
33+
*
34+
* case Poetry = 'poetry'; // no explicit metadata — falls back to docblock
35+
* }
36+
* ```
37+
*
38+
* Precedence rules match the rest of the description system: an explicit `description` wins
39+
* over any docblock summary on the case; an explicit `deprecationReason` wins over any
40+
* `@deprecated` tag in the case docblock. Passing an empty-string description deliberately
41+
* publishes an empty description and suppresses the docblock fallback at that site (see the
42+
* {@see \TheCodingMachine\GraphQLite\Utils\DescriptionResolver} for details).
43+
*/
44+
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
45+
final class EnumValue
46+
{
47+
public function __construct(
48+
public readonly string|null $description = null,
49+
public readonly string|null $deprecationReason = null,
50+
) {
51+
}
52+
}

src/Mappers/Root/EnumTypeMapper.php

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,31 @@ private function mapByClassName(string $enumClass): EnumType|null
139139
: null,
140140
);
141141

142-
/** @var array<string, string> $enumCaseDescriptions */
142+
/** @var array<string, string|null> $enumCaseDescriptions */
143143
$enumCaseDescriptions = [];
144144
/** @var array<string, string> $enumCaseDeprecationReasons */
145145
$enumCaseDeprecationReasons = [];
146146

147147
foreach ($reflectionEnum->getCases() as $reflectionEnumCase) {
148148
$docBlock = $this->docBlockFactory->create($reflectionEnumCase);
149+
$enumValueAttribute = $this->annotationReader->getEnumValueAnnotation($reflectionEnumCase);
150+
151+
$enumCaseDescriptions[$reflectionEnumCase->getName()] = $this->descriptionResolver->resolve(
152+
$enumValueAttribute?->description,
153+
$docBlock->getSummary() ?: null,
154+
);
155+
156+
$explicitDeprecation = $enumValueAttribute?->deprecationReason;
157+
if ($explicitDeprecation !== null) {
158+
// Explicit `deprecationReason` always wins; an empty string deliberately clears
159+
// any @deprecated tag on the case docblock the same way an empty description
160+
// blocks the docblock fallback.
161+
if ($explicitDeprecation !== '') {
162+
$enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = $explicitDeprecation;
163+
}
164+
continue;
165+
}
149166

150-
$enumCaseDescriptions[$reflectionEnumCase->getName()] = $this->descriptionResolver->isDocblockFallbackEnabled()
151-
? ($docBlock->getSummary() ?: null)
152-
: null;
153167
$deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null;
154168

155169
// phpcs:ignore
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Annotations;
6+
7+
use PHPUnit\Framework\TestCase;
8+
9+
class EnumValueTest extends TestCase
10+
{
11+
public function testDefaults(): void
12+
{
13+
$enumValue = new EnumValue();
14+
15+
$this->assertNull($enumValue->description);
16+
$this->assertNull($enumValue->deprecationReason);
17+
}
18+
19+
public function testDescriptionOnly(): void
20+
{
21+
$enumValue = new EnumValue(description: 'Fiction genre.');
22+
23+
$this->assertSame('Fiction genre.', $enumValue->description);
24+
$this->assertNull($enumValue->deprecationReason);
25+
}
26+
27+
public function testDeprecationReasonOnly(): void
28+
{
29+
$enumValue = new EnumValue(deprecationReason: 'Use Essay instead.');
30+
31+
$this->assertNull($enumValue->description);
32+
$this->assertSame('Use Essay instead.', $enumValue->deprecationReason);
33+
}
34+
35+
public function testBothValues(): void
36+
{
37+
$enumValue = new EnumValue(
38+
description: 'Fiction works.',
39+
deprecationReason: 'Use a subgenre.',
40+
);
41+
42+
$this->assertSame('Fiction works.', $enumValue->description);
43+
$this->assertSame('Use a subgenre.', $enumValue->deprecationReason);
44+
}
45+
46+
public function testDescriptionPreservesEmptyString(): void
47+
{
48+
// '' is the deliberate "explicit empty" signal that blocks docblock fallback downstream.
49+
$enumValue = new EnumValue(description: '');
50+
51+
$this->assertSame('', $enumValue->description);
52+
}
53+
}

tests/Fixtures/Description/Genre.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace TheCodingMachine\GraphQLite\Fixtures\Description;
66

7+
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
78
use TheCodingMachine\GraphQLite\Annotations\Type;
89

910
/**
@@ -12,7 +13,15 @@
1213
#[Type(description: 'Editorial classification of a book.')]
1314
enum Genre: string
1415
{
16+
#[EnumValue(description: 'Fiction works including novels and short stories.')]
1517
case Fiction = 'fiction';
18+
19+
/**
20+
* This docblock description should appear on the NonFiction enum value because no
21+
* #[EnumValue] attribute is declared — it exercises the docblock fallback.
22+
*/
1623
case NonFiction = 'non-fiction';
24+
25+
#[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')]
1726
case Poetry = 'poetry';
1827
}

tests/Integration/DescriptionTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,54 @@ public function testExplicitDescriptionOnNativeEnumViaType(): void
9696
$this->assertSame('Editorial classification of a book.', $genreType->description);
9797
}
9898

99+
public function testEnumValueAttributeProvidesCaseDescription(): void
100+
{
101+
$schema = $this->buildSchema(Book::class);
102+
103+
$genreType = $schema->getType('Genre');
104+
$fictionValue = $genreType->getValue('Fiction');
105+
$this->assertSame('Fiction works including novels and short stories.', $fictionValue->description);
106+
}
107+
108+
public function testEnumCaseWithoutAttributeFallsBackToDocblock(): void
109+
{
110+
$schema = $this->buildSchema(Book::class);
111+
112+
$genreType = $schema->getType('Genre');
113+
$nonFictionValue = $genreType->getValue('NonFiction');
114+
// The NonFiction case has no #[EnumValue] attribute, so its description comes from the docblock.
115+
$this->assertNotNull($nonFictionValue->description);
116+
$this->assertStringContainsString(
117+
'This docblock description should appear on the NonFiction enum value',
118+
$nonFictionValue->description,
119+
);
120+
}
121+
122+
public function testEnumValueAttributeProvidesDeprecationReason(): void
123+
{
124+
$schema = $this->buildSchema(Book::class);
125+
126+
$genreType = $schema->getType('Genre');
127+
$poetryValue = $genreType->getValue('Poetry');
128+
$this->assertSame('Use Fiction::Verse instead.', $poetryValue->deprecationReason);
129+
}
130+
131+
public function testDisablingDocblockFallbackSuppressesEnumCaseDescription(): void
132+
{
133+
$schema = $this->buildSchema(Book::class, docblockDescriptions: false);
134+
135+
$genreType = $schema->getType('Genre');
136+
137+
// Fiction has an explicit #[EnumValue] description — still present.
138+
$this->assertSame(
139+
'Fiction works including novels and short stories.',
140+
$genreType->getValue('Fiction')->description,
141+
);
142+
143+
// NonFiction relied on its docblock summary — with the toggle off, it must disappear.
144+
$this->assertNull($genreType->getValue('NonFiction')->description);
145+
}
146+
99147
public function testExtendTypeSuppliesDescriptionWhenBaseTypeHasNone(): void
100148
{
101149
$schema = $this->buildSchema(Book::class);

website/docs/annotations-reference.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,30 @@ Attribute | Compulsory | Type | Definition
311311
*for* | *yes* | string | The name of the PHP parameter
312312
*constraint* | *yes | annotation | One (or many) Symfony validation attributes.
313313

314+
## #[EnumValue]
315+
316+
The `#[EnumValue]` attribute attaches GraphQL schema metadata (description, deprecation reason)
317+
to an individual case of a PHP 8.1+ native enum that is exposed as a GraphQL enum type.
318+
319+
**Applies on**: cases of an enum annotated (directly or indirectly) with `#[Type]`.
320+
321+
Attribute | Compulsory | Type | Definition
322+
------------------|------------|--------|-----------
323+
description | *no* | string | Description of the enum value. When omitted, the case's PHP docblock summary is used (see [schema descriptions](descriptions.md#enum-value-descriptions)). An explicit empty string `''` deliberately suppresses the docblock fallback.
324+
deprecationReason | *no* | string | Deprecation reason published to the schema. When omitted, the `@deprecated` tag on the case docblock is used. An explicit empty string `''` deliberately clears any inherited `@deprecated` tag.
325+
326+
```php
327+
#[Type]
328+
enum Genre: string
329+
{
330+
#[EnumValue(description: 'Fiction works including novels and short stories.')]
331+
case Fiction = 'fiction';
332+
333+
#[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')]
334+
case Poetry = 'poetry';
335+
}
336+
```
337+
314338
## ~~@EnumType~~
315339

316340
*Deprecated: Use [PHP 8.1's native Enums](https://www.php.net/manual/en/language.types.enumerations.php) instead with a [#[Type]](#type-annotation).*

website/docs/descriptions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The attributes that accept `description`:
3737
- `#[Type]`, `#[ExtendType]` — object and enum types
3838
- `#[Factory]` — input types produced by factories
3939
- `#[Field]`, `#[Input]`, `#[SourceField]`, `#[MagicField]` — fields on output and input types
40+
- `#[EnumValue]` — individual cases of an enum type (see [Enum value descriptions](#enum-value-descriptions))
4041

4142
## Docblock fallback
4243

@@ -88,6 +89,46 @@ disabling the whole fallback, pass an empty string:
8889
public function internalOnly(): Foo { /* ... */ }
8990
```
9091

92+
## Enum value descriptions
93+
94+
Native PHP 8.1 enums mapped to GraphQL enum types get per-case metadata via the `#[EnumValue]`
95+
attribute applied to individual cases:
96+
97+
```php
98+
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
99+
use TheCodingMachine\GraphQLite\Annotations\Type;
100+
101+
#[Type]
102+
enum Genre: string
103+
{
104+
#[EnumValue(description: 'Fiction works including novels and short stories.')]
105+
case Fiction = 'fiction';
106+
107+
#[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')]
108+
case Poetry = 'poetry';
109+
110+
/**
111+
* Works grounded in verifiable facts.
112+
*/
113+
case NonFiction = 'non-fiction'; // no attribute — description comes from the docblock
114+
}
115+
```
116+
117+
The attribute name mirrors the GraphQL specification's term ("enum values", see
118+
[spec §3.5.2](https://spec.graphql.org/October2021/#sec-Enum-Values)) and matches webonyx/graphql-php's
119+
`EnumValueDefinition`. The underlying PHP construct is a `case`; the GraphQL element it produces
120+
is an enum value.
121+
122+
`#[EnumValue]` accepts:
123+
124+
- `description` — schema description for this enum value. Omitting it falls back to the case
125+
docblock summary, subject to the same precedence rules as every other attribute's
126+
`description` argument. An explicit empty string `''` deliberately suppresses the docblock
127+
fallback.
128+
- `deprecationReason` — published as the enum value's `deprecationReason` in the schema.
129+
Omitting it falls back to the `@deprecated` tag on the case docblock. An explicit empty string
130+
`''` deliberately clears any inherited `@deprecated` tag.
131+
91132
## Description uniqueness on `#[ExtendType]`
92133

93134
A GraphQL type has exactly one description, so GraphQLite enforces that the description for a

0 commit comments

Comments
 (0)