Skip to content

Commit 9175dc2

Browse files
authored
feat: explicit description attribute (+ #[EnumValue]) + SchemaFactory docblock toggle (#793)
* feat: explicit description attribute + SchemaFactory docblock toggle Adds explicit `description` argument to every schema-defining attribute (Query, Mutation, Subscription, Type, ExtendType, Factory) and a SchemaFactory toggle to disable the docblock-as-description fallback. Addresses #453 and the type-level portion of #740. Behaviour highlights: - Explicit attribute description always wins; `''` deliberately blocks the docblock fallback; `null` falls through when the toggle is on. - `setDocblockDescriptionsEnabled(false)` suppresses every docblock fallback path so internal developer docblocks stop leaking to public schema consumers. - Duplicate descriptions across `#[Type]` + `#[ExtendType]` (or multiple `#[ExtendType]`s on the same class) now throw a DuplicateDescriptionOnTypeException naming every offending source. - `InputTypeGenerator::mapFactoryMethod()` closes the long-standing `// TODO: add comment argument.` — factory-produced input types now participate in description resolution. Consistency renames (breaking): - `QueryFieldDescriptor`/`InputFieldDescriptor` `$comment` -> `$description` and paired method renames (getDescription/withDescription/ withAddedDescriptionLines). - `AbstractRequest` -> `AbstractGraphQLElement` and `AnnotationReader::getRequestAnnotation()` -> `getGraphQLElementAnnotation()`. The new name reflects the class's role as the shared base for GraphQL schema-element attributes. Tests: +26 new (resolver precedence matrix, per-attribute integration, conflict exception, docblock-off regression). Full suite 528 passing across cs-check, phpstan, and phpunit. * 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. * feat: deprecation notice for enum cases missing #[EnumValue] Announces the upcoming opt-in migration for PHP enums mapped to GraphQL enum types. Today every case is automatically exposed; a future major release will require #[EnumValue] on each case that should participate in the schema, matching the opt-in model #[Field] already uses for methods and properties on object types. The practical win is selective exposure: an internal enum case can opt out of the public schema simply by omitting #[EnumValue], instead of forcing schema authors to split an enum or rename cases. Runtime behaviour is unchanged. When an enum annotated with #[Type] exposes any case without an #[EnumValue] attribute, EnumTypeMapper emits an E_USER_DEPRECATED notice that names the specific cases so the migration path is mechanical — matching graphqlite's existing deprecation pattern (e.g. addControllerNamespace, setGlobTTL). Tests: +1 explicit deprecation assertion (using PHPUnit 11's expectUserDeprecationMessageMatches). Existing integration tests that exercise the Genre fixture surface the notice via PHPUnit's deprecation reporting, confirming the warning fires in realistic scenarios. Full suite 538/538 green across cs-check, phpstan, and phpunit; 4 intentional deprecation events reported. Docs: descriptions.md gains a "Future migration" subsection describing the planned opt-in model and the current advisory notice. * docs: clarify runtime behaviour for unmapped enum cases after future flip The advisory deprecation already announces the opt-in migration. Add a second paragraph spelling out what happens if a resolver returns a case that lacks #[EnumValue] after the default flips: webonyx/graphql-php's native enum serialization rejects it, the same spec-compliant behaviour that applies to any unknown enum value. That is the mechanism that makes selective exposure safe — internal cases cannot leak via a resolver — and clarifies that omitting #[EnumValue] is a deliberate 'do not expose this value' signal, not an oversight to work around. * fix: narrow enum advisory to fire only when zero cases carry #[EnumValue] Partial annotation is the mechanism that will hide internal cases from the public schema once the default flips to opt-in; leaving some cases unannotated is deliberate and must not produce a warning. Under the previous logic the advisory fired for every partial annotation, which would punish exactly the pattern the migration encourages. New rule: fire the E_USER_DEPRECATED advisory only when a #[Type]-mapped enum declares zero #[EnumValue] attributes across all its cases — the signal that the developer has not yet engaged with the opt-in model. Annotating even a single case acknowledges the migration and silences the notice; from that point any combination of annotated and unannotated cases is correct and intentional. Implementation: EnumTypeMapper::mapByClassName() now tracks a single `sawAnyEnumValueAttribute` flag while iterating cases and emits the notice only when the flag stays false. Message rewritten to explain the "at least one case" requirement and point the reader at the intended migration path — including the fact that a bare #[EnumValue] on a single case is a valid acknowledgement. Tests: - Replaces the previous "any case missing" assertion with testEnumWithZeroEnumValueAttributesTriggersDeprecation using a new DescriptionLegacyEnum/Era fixture that declares no #[EnumValue] at all. - Adds testEnumWithPartialEnumValueAttributesIsSilent to lock in the partial-annotation contract against regressions. - Pre-existing Color/Size/Position fixtures gain a bare #[EnumValue] on their first case so their integration tests stop triggering the advisory — demonstrates the minimal upgrade path recommended in the docs. Full suite 539/539 green across cs-check, phpstan, and phpunit, with exactly 1 intentional deprecation coming from the dedicated advisory test. The docs in descriptions.md are updated to reflect the corrected semantics — "partial annotation is intentional and silent". * docs: trim the enum migration subsection — short-lived transitional content * refactor: sawAnyEnumValueAttribute -> hasEnumValueAttribute Matches PHP's isser/haser naming convention and reads as a present-state question instead of imperative history. Also aligns the private helper name with the boolean semantics (plural -> singular since the check is 'has at least one attribute anywhere on the enum'). * docs: drop the 'bare single #[EnumValue] to silence' shortcut recommendation Recommending users annotate a single case to silence the advisory is actively misleading: once the default flips, every other case would disappear from the schema — the exact opposite of what a user reaching for the shortcut wanted. The honest recommendation is 'annotate every case you want to keep exposed', which aligns their intent with the post-flip behaviour. - Warning message rewritten around 'add to every case you want exposed, omit only from cases you want hidden' instead of 'at least one case silences this notice'. - descriptions.md migration subsection mirrors the same framing. - Color/Size/Position test fixtures now annotate every case, not just the first one, so they demonstrate the correct migration pattern rather than the misleading shortcut.
1 parent a5f7549 commit 9175dc2

57 files changed

Lines changed: 1642 additions & 191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/AnnotationReader.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
namespace TheCodingMachine\GraphQLite;
66

77
use ReflectionClass;
8+
use ReflectionEnumUnitCase;
89
use ReflectionMethod;
910
use ReflectionParameter;
1011
use ReflectionProperty;
11-
use TheCodingMachine\GraphQLite\Annotations\AbstractRequest;
12+
use TheCodingMachine\GraphQLite\Annotations\AbstractGraphQLElement;
1213
use TheCodingMachine\GraphQLite\Annotations\Decorate;
14+
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
1315
use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException;
1416
use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException;
1517
use TheCodingMachine\GraphQLite\Annotations\ExtendType;
@@ -195,11 +197,29 @@ public function getExtendTypeAnnotation(ReflectionClass $refClass): ExtendType|n
195197
return $extendType;
196198
}
197199

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

204224
return $queryAnnotation;
205225
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Annotations;
6+
7+
/**
8+
* Shared base for every attribute that declares an invokable GraphQL schema element with a
9+
* return type — {@see Query}, {@see Mutation}, {@see Subscription}, and {@see Field}. Each of
10+
* those attributes inherits a GraphQL-level name, an explicit return type override, and a
11+
* schema description from this class.
12+
*/
13+
abstract class AbstractGraphQLElement
14+
{
15+
private string|null $outputType;
16+
17+
private string|null $name;
18+
19+
private string|null $description;
20+
21+
/** @param mixed[] $attributes */
22+
public function __construct(
23+
array $attributes = [],
24+
string|null $name = null,
25+
string|null $outputType = null,
26+
string|null $description = null,
27+
) {
28+
$this->outputType = $outputType ?? $attributes['outputType'] ?? null;
29+
$this->name = $name ?? $attributes['name'] ?? null;
30+
$this->description = $description ?? $attributes['description'] ?? null;
31+
}
32+
33+
/**
34+
* Returns the GraphQL return type for this schema element (as a string).
35+
* The string can represent the FQCN of the type or an entry in the container resolving to the GraphQL type.
36+
*/
37+
public function getOutputType(): string|null
38+
{
39+
return $this->outputType;
40+
}
41+
42+
/**
43+
* Returns the GraphQL name of the query/mutation/subscription/field.
44+
* If not specified, the name of the PHP method is used instead.
45+
*/
46+
public function getName(): string|null
47+
{
48+
return $this->name;
49+
}
50+
51+
/**
52+
* Returns the explicit description for this schema element, or null if none was provided.
53+
*
54+
* A null return means "no explicit description" and the schema builder may fall back to the
55+
* docblock summary (if docblock descriptions are enabled on the SchemaFactory). An explicit
56+
* empty string blocks the docblock fallback and produces an empty description.
57+
*/
58+
public function getDescription(): string|null
59+
{
60+
return $this->description;
61+
}
62+
}

src/Annotations/AbstractRequest.php

Lines changed: 0 additions & 37 deletions
This file was deleted.

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+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Annotations\Exceptions;
6+
7+
use BadMethodCallException;
8+
9+
use function implode;
10+
11+
/**
12+
* Thrown when both a #[Type] attribute and one or more #[ExtendType] attributes (or multiple
13+
* #[ExtendType] attributes alone) declare a `description` for the same GraphQL type.
14+
*
15+
* A GraphQL type has exactly one description, so GraphQLite must be able to pick a single
16+
* canonical source. Rather than silently resolving the conflict via declaration order, the
17+
* schema builder rejects the ambiguity with a clear error listing every offending source.
18+
*
19+
* Descriptions may therefore live on the base #[Type] OR on exactly one #[ExtendType], never
20+
* on both, and never on more than one #[ExtendType] for the same target class.
21+
*/
22+
class DuplicateDescriptionOnTypeException extends BadMethodCallException
23+
{
24+
/**
25+
* @param class-string<object> $targetClass
26+
* @param list<string> $sources Human-readable descriptions of the attribute sources
27+
* that contributed a description (e.g. class names).
28+
*/
29+
public static function forType(string $targetClass, array $sources): self
30+
{
31+
return new self(
32+
'A GraphQL type may only have a description declared on the #[Type] attribute OR on exactly one #[ExtendType] attribute, never more than one. '
33+
. 'Target type class "' . $targetClass . '" received descriptions from multiple sources: '
34+
. implode(', ', $sources) . '. '
35+
. 'Keep the description on the #[Type] attribute, or move it to at most one #[ExtendType] attribute.',
36+
);
37+
}
38+
}

src/Annotations/ExtendType.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ class ExtendType
2121
/** @var class-string<object>|null */
2222
private string|null $class;
2323
private string|null $name;
24+
private string|null $description;
2425

2526
/** @param mixed[] $attributes */
2627
public function __construct(
2728
array $attributes = [],
2829
string|null $class = null,
2930
string|null $name = null,
31+
string|null $description = null,
3032
) {
3133
$className = isset($attributes['class']) ? ltrim($attributes['class'], '\\') : null;
3234
$className = $className ?? $class;
@@ -35,6 +37,7 @@ public function __construct(
3537
}
3638
$this->name = $name ?? $attributes['name'] ?? null;
3739
$this->class = $className;
40+
$this->description = $description ?? $attributes['description'] ?? null;
3841
if (! $this->class && ! $this->name) {
3942
throw new BadMethodCallException('In attribute #[ExtendType], missing one of the compulsory parameter "class" or "name".');
4043
}
@@ -55,4 +58,17 @@ public function getName(): string|null
5558
{
5659
return $this->name;
5760
}
61+
62+
/**
63+
* Returns the explicit description contributed by this type extension, or null if none was provided.
64+
*
65+
* A GraphQL type carries exactly one description. If both the base #[Type] and this #[ExtendType]
66+
* (or multiple #[ExtendType] attributes targeting the same class) provide a description, the
67+
* schema builder throws DuplicateDescriptionOnTypeException. Descriptions may therefore live on
68+
* #[Type] OR on at most one #[ExtendType], never on both.
69+
*/
70+
public function getDescription(): string|null
71+
{
72+
return $this->description;
73+
}
5874
}

src/Annotations/Factory.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ class Factory
1515
{
1616
private string|null $name;
1717
private bool $default;
18+
private string|null $description;
1819

1920
/** @param mixed[] $attributes */
20-
public function __construct(array $attributes = [], string|null $name = null, bool|null $default = null)
21-
{
21+
public function __construct(
22+
array $attributes = [],
23+
string|null $name = null,
24+
bool|null $default = null,
25+
string|null $description = null,
26+
) {
2227
$this->name = $name ?? $attributes['name'] ?? null;
2328
// This IS the default if no name is set and no "default" attribute is passed.
2429
$this->default = $default ?? $attributes['default'] ?? ! isset($attributes['name']);
30+
$this->description = $description ?? $attributes['description'] ?? null;
2531

2632
if ($this->name === null && $this->default === false) {
2733
throw new GraphQLRuntimeException('A #[Factory] that has "default=false" attribute must be given a name (i.e. add a name="FooBarInput" attribute).');
@@ -44,4 +50,17 @@ public function isDefault(): bool
4450
{
4551
return $this->default;
4652
}
53+
54+
/**
55+
* Returns the explicit description for the GraphQL input type produced by this factory,
56+
* or null if none was provided.
57+
*
58+
* A null return means "no explicit description" and the schema builder may fall back to the
59+
* docblock summary (if docblock descriptions are enabled on the SchemaFactory). An explicit
60+
* empty string blocks the docblock fallback and produces an empty description.
61+
*/
62+
public function getDescription(): string|null
63+
{
64+
return $this->description;
65+
}
4766
}

src/Annotations/Field.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use const E_USER_DEPRECATED;
1212

1313
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
14-
class Field extends AbstractRequest
14+
class Field extends AbstractGraphQLElement
1515
{
1616
private string|null $prefetchMethod;
1717

src/Annotations/Mutation.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
use Attribute;
88

99
#[Attribute(Attribute::TARGET_METHOD)]
10-
class Mutation extends AbstractRequest
10+
class Mutation extends AbstractGraphQLElement
1111
{
1212
}

src/Annotations/Query.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
use Attribute;
88

99
#[Attribute(Attribute::TARGET_METHOD)]
10-
class Query extends AbstractRequest
10+
class Query extends AbstractGraphQLElement
1111
{
1212
}

0 commit comments

Comments
 (0)