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]`