Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/AnnotationReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
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\EnumValue;
use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException;
use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException;
use TheCodingMachine\GraphQLite\Annotations\ExtendType;
Expand Down Expand Up @@ -195,11 +197,29 @@ public function getExtendTypeAnnotation(ReflectionClass $refClass): ExtendType|n
return $extendType;
}

/** @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