Skip to content
Open
28 changes: 24 additions & 4 deletions src/AnnotationReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
namespace TheCodingMachine\GraphQLite;

use ReflectionClass;
use ReflectionEnumUnitCase;
use ReflectionMethod;
use ReflectionParameter;
use ReflectionProperty;
use TheCodingMachine\GraphQLite\Annotations\AbstractRequest;
use TheCodingMachine\GraphQLite\Annotations\AbstractGraphQLElement;
use TheCodingMachine\GraphQLite\Annotations\Decorate;
use TheCodingMachine\GraphQLite\Annotations\EnumType;
use TheCodingMachine\GraphQLite\Annotations\EnumValue;
use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException;
use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException;
use TheCodingMachine\GraphQLite\Annotations\ExtendType;
Expand Down Expand Up @@ -201,11 +203,29 @@ public function getEnumTypeAnnotation(ReflectionClass $refClass): EnumType|null
return $this->getClassAnnotation($refClass, EnumType::class);
}

/** @param class-string<AbstractRequest> $annotationClass */
public function getRequestAnnotation(ReflectionMethod $refMethod, string $annotationClass): AbstractRequest|null
/**
* Returns the {@see EnumValue} attribute declared on a PHP enum case, or null when no
* attribute is present. Callers use this to resolve the explicit description and deprecation
* reason before falling back to docblock parsing.
*/
public function getEnumValueAnnotation(ReflectionEnumUnitCase $refCase): EnumValue|null
{
$attribute = $refCase->getAttributes(EnumValue::class)[0] ?? null;
if ($attribute === null) {
return null;
}

$instance = $attribute->newInstance();
assert($instance instanceof EnumValue);

return $instance;
}

/** @param class-string<AbstractGraphQLElement> $annotationClass */
public function getGraphQLElementAnnotation(ReflectionMethod $refMethod, string $annotationClass): AbstractGraphQLElement|null
{
$queryAnnotation = $this->getMethodAnnotation($refMethod, $annotationClass);
assert($queryAnnotation instanceof AbstractRequest || $queryAnnotation === null);
assert($queryAnnotation instanceof AbstractGraphQLElement || $queryAnnotation === null);

return $queryAnnotation;
}
Expand Down
62 changes: 62 additions & 0 deletions src/Annotations/AbstractGraphQLElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Annotations;

/**
* Shared base for every attribute that declares an invokable GraphQL schema element with a
* return type — {@see Query}, {@see Mutation}, {@see Subscription}, and {@see Field}. Each of
* those attributes inherits a GraphQL-level name, an explicit return type override, and a
* schema description from this class.
*/
abstract class AbstractGraphQLElement
{
private string|null $outputType;

private string|null $name;

private string|null $description;

/** @param mixed[] $attributes */
public function __construct(
array $attributes = [],
string|null $name = null,
string|null $outputType = null,
string|null $description = null,
) {
$this->outputType = $outputType ?? $attributes['outputType'] ?? null;
$this->name = $name ?? $attributes['name'] ?? null;
$this->description = $description ?? $attributes['description'] ?? null;
}

/**
* Returns the GraphQL return type for this schema element (as a string).
* The string can represent the FQCN of the type or an entry in the container resolving to the GraphQL type.
*/
public function getOutputType(): string|null
{
return $this->outputType;
}

/**
* Returns the GraphQL name of the query/mutation/subscription/field.
* If not specified, the name of the PHP method is used instead.
*/
public function getName(): string|null
{
return $this->name;
}

/**
* Returns the explicit description for this schema element, or null if none was provided.
*
* A null return means "no explicit description" and the schema builder may fall back to the
* docblock summary (if docblock descriptions are enabled on the SchemaFactory). An explicit
* empty string blocks the docblock fallback and produces an empty description.
*/
public function getDescription(): string|null
{
return $this->description;
}
}
37 changes: 0 additions & 37 deletions src/Annotations/AbstractRequest.php

This file was deleted.

52 changes: 52 additions & 0 deletions src/Annotations/EnumValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Annotations;

use Attribute;

/**
* Attaches GraphQL metadata to an individual enum case.
*
* Applied to cases of a PHP 8.1+ native enum exposed as a GraphQL enum type, this attribute
* provides the schema description and deprecation reason for that value without relying on
* docblock parsing — mirroring the explicit {@see Type::$description} and
* {@see Field::$description} pattern that the rest of the attribute system uses.
*
* The attribute is named after the GraphQL specification's term for an enum member ("enum
* value", see §3.5.2 of the spec and the `__EnumValue` introspection type), which matches the
* GraphQL-spec-mirroring naming convention of every other graphqlite attribute (`#[Type]`,
* `#[Field]`, `#[Query]`, etc.). The underlying PHP language construct is `case`; the GraphQL
* schema element it produces is an enum value.
*
* Example:
* ```php
* #[Type]
* enum Genre: string
* {
* #[EnumValue(description: 'Fiction works including novels and short stories.')]
* case Fiction = 'fiction';
*
* #[EnumValue(deprecationReason: 'Use NonFiction::Essay instead.')]
* case Essay = 'essay';
*
* case Poetry = 'poetry'; // no explicit metadata — falls back to docblock
* }
* ```
*
* Precedence rules match the rest of the description system: an explicit `description` wins
* over any docblock summary on the case; an explicit `deprecationReason` wins over any
* `@deprecated` tag in the case docblock. Passing an empty-string description deliberately
* publishes an empty description and suppresses the docblock fallback at that site (see the
* {@see \TheCodingMachine\GraphQLite\Utils\DescriptionResolver} for details).
*/
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
final class EnumValue
{
public function __construct(
public readonly string|null $description = null,
public readonly string|null $deprecationReason = null,
) {
}
}
38 changes: 38 additions & 0 deletions src/Annotations/Exceptions/DuplicateDescriptionOnTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Annotations\Exceptions;

use BadMethodCallException;

use function implode;

/**
* Thrown when both a #[Type] attribute and one or more #[ExtendType] attributes (or multiple
* #[ExtendType] attributes alone) declare a `description` for the same GraphQL type.
*
* A GraphQL type has exactly one description, so GraphQLite must be able to pick a single
* canonical source. Rather than silently resolving the conflict via declaration order, the
* schema builder rejects the ambiguity with a clear error listing every offending source.
*
* Descriptions may therefore live on the base #[Type] OR on exactly one #[ExtendType], never
* on both, and never on more than one #[ExtendType] for the same target class.
*/
class DuplicateDescriptionOnTypeException extends BadMethodCallException
{
/**
* @param class-string<object> $targetClass
* @param list<string> $sources Human-readable descriptions of the attribute sources
* that contributed a description (e.g. class names).
*/
public static function forType(string $targetClass, array $sources): self
{
return new self(
'A GraphQL type may only have a description declared on the #[Type] attribute OR on exactly one #[ExtendType] attribute, never more than one. '
. 'Target type class "' . $targetClass . '" received descriptions from multiple sources: '
. implode(', ', $sources) . '. '
. 'Keep the description on the #[Type] attribute, or move it to at most one #[ExtendType] attribute.',
);
}
}
16 changes: 16 additions & 0 deletions src/Annotations/ExtendType.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ class ExtendType
/** @var class-string<object>|null */
private string|null $class;
private string|null $name;
private string|null $description;

/** @param mixed[] $attributes */
public function __construct(
array $attributes = [],
string|null $class = null,
string|null $name = null,
string|null $description = null,
) {
$className = isset($attributes['class']) ? ltrim($attributes['class'], '\\') : null;
$className = $className ?? $class;
Expand All @@ -35,6 +37,7 @@ public function __construct(
}
$this->name = $name ?? $attributes['name'] ?? null;
$this->class = $className;
$this->description = $description ?? $attributes['description'] ?? null;
if (! $this->class && ! $this->name) {
throw new BadMethodCallException('In attribute #[ExtendType], missing one of the compulsory parameter "class" or "name".');
}
Expand All @@ -55,4 +58,17 @@ public function getName(): string|null
{
return $this->name;
}

/**
* Returns the explicit description contributed by this type extension, or null if none was provided.
*
* A GraphQL type carries exactly one description. If both the base #[Type] and this #[ExtendType]
* (or multiple #[ExtendType] attributes targeting the same class) provide a description, the
* schema builder throws DuplicateDescriptionOnTypeException. Descriptions may therefore live on
* #[Type] OR on at most one #[ExtendType], never on both.
*/
public function getDescription(): string|null
{
return $this->description;
}
}
23 changes: 21 additions & 2 deletions src/Annotations/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ class Factory
{
private string|null $name;
private bool $default;
private string|null $description;

/** @param mixed[] $attributes */
public function __construct(array $attributes = [], string|null $name = null, bool|null $default = null)
{
public function __construct(
array $attributes = [],
string|null $name = null,
bool|null $default = null,
string|null $description = null,
) {
$this->name = $name ?? $attributes['name'] ?? null;
// This IS the default if no name is set and no "default" attribute is passed.
$this->default = $default ?? $attributes['default'] ?? ! isset($attributes['name']);
$this->description = $description ?? $attributes['description'] ?? null;

if ($this->name === null && $this->default === false) {
throw new GraphQLRuntimeException('A #[Factory] that has "default=false" attribute must be given a name (i.e. add a name="FooBarInput" attribute).');
Expand All @@ -44,4 +50,17 @@ public function isDefault(): bool
{
return $this->default;
}

/**
* Returns the explicit description for the GraphQL input type produced by this factory,
* or null if none was provided.
*
* A null return means "no explicit description" and the schema builder may fall back to the
* docblock summary (if docblock descriptions are enabled on the SchemaFactory). An explicit
* empty string blocks the docblock fallback and produces an empty description.
*/
public function getDescription(): string|null
{
return $this->description;
}
}
2 changes: 1 addition & 1 deletion src/Annotations/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use const E_USER_DEPRECATED;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Field extends AbstractRequest
class Field extends AbstractGraphQLElement
{
private string|null $prefetchMethod;

Expand Down
2 changes: 1 addition & 1 deletion src/Annotations/Mutation.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class Mutation extends AbstractRequest
class Mutation extends AbstractGraphQLElement
{
}
2 changes: 1 addition & 1 deletion src/Annotations/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class Query extends AbstractRequest
class Query extends AbstractGraphQLElement
{
}
2 changes: 1 addition & 1 deletion src/Annotations/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class Subscription extends AbstractRequest
class Subscription extends AbstractGraphQLElement
{
}
16 changes: 16 additions & 0 deletions src/Annotations/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class Type implements TypeInterface

private bool $useEnumValues = false;

private string|null $description = null;

/**
* @param mixed[] $attributes
* @param class-string<object>|null $class
Expand All @@ -45,6 +47,7 @@ public function __construct(
bool|null $default = null,
bool|null $external = null,
bool|null $useEnumValues = null,
string|null $description = null,
) {
$external = $external ?? $attributes['external'] ?? null;
$class = $class ?? $attributes['class'] ?? null;
Expand All @@ -59,6 +62,7 @@ public function __construct(
// If no value is passed for default, "default" = true
$this->default = $default ?? $attributes['default'] ?? true;
$this->useEnumValues = $useEnumValues ?? $attributes['useEnumValues'] ?? false;
$this->description = $description ?? $attributes['description'] ?? null;

if ($external === null) {
return;
Expand Down Expand Up @@ -127,4 +131,16 @@ public function useEnumValues(): bool
{
return $this->useEnumValues;
}

/**
* Returns the explicit description for this GraphQL type, or null if none was provided.
*
* A null return means "no explicit description" and the schema builder may fall back to the
* docblock summary (if docblock descriptions are enabled on the SchemaFactory). An explicit
* empty string blocks the docblock fallback and produces an empty description.
*/
public function getDescription(): string|null
{
return $this->description;
}
}
Loading
Loading