From 42b0fd09b595fe874d54d331ab375ee0a8bdf19c Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Tue, 18 Aug 2026 19:06:02 -0400 Subject: [PATCH] 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. --- src/Mappers/Root/EnumTypeMapper.php | 72 +++++++------ src/Types/EnumType.php | 22 ++-- src/Types/ExposedEnumCase.php | 22 ++++ .../EnumExposure/EnumExposureController.php | 16 +++ tests/Fixtures/EnumExposure/PublishStatus.php | 28 +++++ tests/Fixtures/EnumExposureLegacy/Weekday.php | 22 ++++ .../EnumExposureLegacy/WeekdayController.php | 16 +++ tests/Integration/DescriptionTest.php | 33 +++--- tests/Integration/EnumExposureTest.php | 100 ++++++++++++++++++ website/docs/CHANGELOG.md | 25 +++++ website/docs/attributes-reference.md | 10 +- website/docs/descriptions.md | 27 +++-- 12 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 src/Types/ExposedEnumCase.php create mode 100644 tests/Fixtures/EnumExposure/EnumExposureController.php create mode 100644 tests/Fixtures/EnumExposure/PublishStatus.php create mode 100644 tests/Fixtures/EnumExposureLegacy/Weekday.php create mode 100644 tests/Fixtures/EnumExposureLegacy/WeekdayController.php create mode 100644 tests/Integration/EnumExposureTest.php diff --git a/src/Mappers/Root/EnumTypeMapper.php b/src/Mappers/Root/EnumTypeMapper.php index 7a63635317..82e6779cf7 100644 --- a/src/Mappers/Root/EnumTypeMapper.php +++ b/src/Mappers/Root/EnumTypeMapper.php @@ -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; @@ -138,64 +139,73 @@ private function mapByClassName(string $enumClass): EnumType|null : null, ); - /** @var array $enumCaseDescriptions */ - $enumCaseDescriptions = []; - /** @var array $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 $annotatedCases */ + $annotatedCases = []; + /** @var list $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 $enumClass */ @@ -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, diff --git a/src/Types/EnumType.php b/src/Types/EnumType.php index 25ff52af43..8849673a10 100644 --- a/src/Types/EnumType.php +++ b/src/Types/EnumType.php @@ -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 $enumName - * @param array $caseDescriptions - * @param array $caseDeprecationReasons + * @param list $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, ]; } diff --git a/src/Types/ExposedEnumCase.php b/src/Types/ExposedEnumCase.php new file mode 100644 index 0000000000..ebd93bd576 --- /dev/null +++ b/src/Types/ExposedEnumCase.php @@ -0,0 +1,22 @@ +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. @@ -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 @@ -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 diff --git a/tests/Integration/EnumExposureTest.php b/tests/Integration/EnumExposureTest.php new file mode 100644 index 0000000000..2561761661 --- /dev/null +++ b/tests/Integration/EnumExposureTest.php @@ -0,0 +1,100 @@ +setAuthenticationService(new VoidAuthenticationService()); + $factory->setAuthorizationService(new VoidAuthorizationService()); + $factory->addNamespace((new ReflectionClass($fixtureClass))->getNamespaceName()); + $factory->setDocblockDescriptionsEnabled($docblockDescriptions); + + return $factory->createSchema(); + } + + public function testMixedEnumExposesOnlyAnnotatedCases(): void + { + $schema = $this->buildSchema(PublishStatus::class); + + $enum = $schema->getType('PublishStatus'); + $this->assertInstanceOf(EnumType::class, $enum); + + $exposedNames = array_map(static fn ($value) => $value->name, $enum->getValues()); + $this->assertSame(['Published', 'Scheduled'], $exposedNames); + + // Annotated cases reach the schema, unannotated internal cases are hidden. + $this->assertNotNull($enum->getValue('Published')); + $this->assertNotNull($enum->getValue('Scheduled')); + $this->assertNull($enum->getValue('Draft')); + $this->assertNull($enum->getValue('Archived')); + + // Metadata wiring stays intact for exposed cases. + $this->assertSame('Visible to everyone.', $enum->getValue('Published')->description); + } + + public function testFullyUnannotatedEnumExposesEveryCase(): void + { + // A fully-unannotated enum stays in legacy mode; resolving it emits the opt-in advisory. + $this->expectUserDeprecationMessageMatches('/declares no #\[EnumValue\] attributes/'); + + $schema = $this->buildSchema(Weekday::class); + + $enum = $schema->getType('Weekday'); + $this->assertInstanceOf(EnumType::class, $enum); + + $exposedNames = array_map(static fn ($value) => $value->name, $enum->getValues()); + $this->assertSame(['Monday', 'Tuesday', 'Wednesday'], $exposedNames); + + // Docblock fallback still populates case descriptions in legacy mode. + $this->assertSame('The first working day of the week.', $enum->getValue('Monday')->description); + } + + public function testLegacyEnumStillSuppressesDocblockCaseDescriptionWhenDisabled(): void + { + // Docblock fallback off: every case is still exposed, but the docblock-derived description + // is dropped. + $this->expectUserDeprecationMessageMatches('/declares no #\[EnumValue\] attributes/'); + + $schema = $this->buildSchema(Weekday::class, docblockDescriptions: false); + + $enum = $schema->getType('Weekday'); + $this->assertInstanceOf(EnumType::class, $enum); + + $this->assertCount(3, $enum->getValues()); + $this->assertNull($enum->getValue('Monday')->description); + } +} diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index 9f81b79b94..c44cb4584d 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -4,6 +4,31 @@ title: Changelog sidebar_label: Changelog --- +## 8.4.0 + +### Breaking Changes + +- `#[EnumValue]` now controls enum case exposure. Once any case of a `#[Type]`-mapped enum carries + `#[EnumValue]`, the enum is in opt-in mode: only annotated cases are exposed and unannotated cases + are hidden from the schema (mirroring `#[Field]`'s opt-in model on classes). A fully-unannotated + mapped enum still exposes every case and emits a deprecation notice. **Migration**: any enum that + intentionally left some cases unannotated (for example, for docblock-only descriptions) must now + add `#[EnumValue]` to every case it wants exposed. + +### New Features + +- `#[EnumValue]` is now the per-case schema-exposure toggle, so internal enum cases can be kept out + of the public schema by omitting the attribute. + ([#826](https://github.com/thecodingmachine/graphqlite/pull/826)) +- [#822 Accept PHP callables as `#[Security]` rules](https://github.com/thecodingmachine/graphqlite/pull/822) + @oojacoboo, alongside the existing expression form, with access to the field context and custom + refusal messages. + +### Bug Fixes + +- [#819 Fix undefined array input type](https://github.com/thecodingmachine/graphqlite/pull/819) + @michael-georgiadis + ## >8.0.0 **For all future changelog details, refer to the [Releases](https://github.com/thecodingmachine/graphqlite/releases). diff --git a/website/docs/attributes-reference.md b/website/docs/attributes-reference.md index 31b50655d9..f9b076e6fd 100644 --- a/website/docs/attributes-reference.md +++ b/website/docs/attributes-reference.md @@ -59,10 +59,16 @@ name | *yes* | string | The GraphQL input type name extended by t ## #[EnumValue] The `#[EnumValue]` attribute attaches GraphQL schema metadata (description, deprecation reason) -to an individual case of a PHP 8.1+ native enum that is exposed as a GraphQL enum type. +to an individual case of a PHP 8.1+ native enum, and controls whether that case is exposed as a +GraphQL enum value. **Applies on**: cases of an enum annotated (directly or indirectly) with `#[Type]`. +**Schema exposure**: the moment any case of a `#[Type]`-mapped enum carries `#[EnumValue]`, the +enum is in opt-in mode and only annotated cases are exposed; unannotated cases are hidden. An +enum with no `#[EnumValue]` on any case stays in legacy mode (every case exposed) and triggers a +deprecation notice. See [enum value descriptions](descriptions.md#enum-value-descriptions). + Attribute | Compulsory | Type | Definition ------------------|------------|--------|----------- 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. @@ -77,6 +83,8 @@ enum Genre: string #[EnumValue(deprecationReason: 'Use Fiction::Verse instead.')] case Poetry = 'poetry'; + + case NonFiction = 'non-fiction'; // no #[EnumValue], so it is hidden from the schema } ``` diff --git a/website/docs/descriptions.md b/website/docs/descriptions.md index 4c427a8640..ffeae92a09 100644 --- a/website/docs/descriptions.md +++ b/website/docs/descriptions.md @@ -91,8 +91,9 @@ public function internalOnly(): Foo { /* ... */ } ## Enum value descriptions -Native PHP 8.1 enums mapped to GraphQL enum types get per-case metadata via the `#[EnumValue]` -attribute applied to individual cases: +Native PHP 8.1 enums mapped to GraphQL enum types use the `#[EnumValue]` attribute on individual +cases both to attach per-case metadata (description, deprecation reason) and to control which +cases appear in the schema: ```php use TheCodingMachine\GraphQLite\Annotations\EnumValue; @@ -110,7 +111,7 @@ enum Genre: string /** * Works grounded in verifiable facts. */ - case NonFiction = 'non-fiction'; // no attribute — description comes from the docblock + case NonFiction = 'non-fiction'; // no #[EnumValue], so it is hidden from the schema } ``` @@ -129,16 +130,20 @@ is an enum value. Omitting it falls back to the `@deprecated` tag on the case docblock. An explicit empty string `''` deliberately clears any inherited `@deprecated` tag. -### Future migration +### Schema exposure -A future major release will require `#[EnumValue]` on each case that should participate in -the schema; unannotated cases will be hidden (mirroring `#[Field]`'s opt-in model). Today -every case is still auto-exposed, so nothing breaks. Add `#[EnumValue]` to every case you -want to keep exposed — omitting it from a case is the mechanism for hiding internal values -once the default flips. +`#[EnumValue]` also controls which cases appear in the schema, following the same opt-in model +as `#[Field]` on classes: -GraphQLite emits a deprecation notice when a `#[Type]`-mapped enum has **zero** -`#[EnumValue]` attributes at all (partial annotation is intentional and stays silent). +- **Opt-in mode**: as soon as **any** case of a `#[Type]`-mapped enum carries `#[EnumValue]`, + only the annotated cases are exposed; every case without the attribute is hidden. In the + `Genre` example above, `NonFiction` has no `#[EnumValue]`, so it does not appear in the schema. +- **Legacy mode**: a mapped enum with **zero** `#[EnumValue]` attributes keeps every case + exposed, and GraphQLite emits a deprecation notice recommending you annotate the cases you + want exposed. (Partial annotation triggers no notice; it is the supported way to hide a case.) + +Add `#[EnumValue]` to every case you want in the public schema; omit it only from cases you want +hidden, such as internal values that should never reach API consumers. ## Description uniqueness on `#[ExtendType]`