diff --git a/CLAUDE.md b/CLAUDE.md index e34342b..dcb88ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Attributes are in `src/Attribute/`: - `#[Entity]` - Marks an entity for GraphQL exposure - `#[Field]` - Exposes a field - `#[Association]` - Exposes an association (relationship) -- `#[ComputedField]` - Exposes derived values from entity methods (placed on public methods); its `type` may be an entity class exposed in the group, and `list: true` makes it a list. An entity type's field is a thunk, as an association's is, so entities may refer to each other +- `#[ComputedField]` - Exposes derived values from entity methods (placed on public methods); its `type` may be an entity class exposed in the group, and `list: true` makes it a list. An entity type's field is a thunk, as an association's is, so entities may refer to each other. Each method parameter is an argument (int/float/string/bool inferred, others typed by the attribute's `args`); values of fields with arguments are cached per set of arguments in `FieldResolver`, and `extract()` leaves those fields out - `#[ExcludeFilters]` - Excludes specific filters ### Event System (PSR-14) diff --git a/docs/attributes.rst b/docs/attributes.rst index add8676..7633059 100644 --- a/docs/attributes.rst +++ b/docs/attributes.rst @@ -265,6 +265,10 @@ is built. which must match a registered type in the TypeContainer, or the class of an entity exposed in the group. * ``list`` - ``true`` when the method returns a list of the type. +* ``args`` - The type of each method parameter which is not an ``int``, + ``float``, ``string`` or ``bool``, by its name. Each parameter of the + method is an argument of the field; see `computed fields + `_. * ``description`` - A description of the computed field. * ``name`` - An override for the field name in GraphQL. If not provided, the name is derived from the method name (``getFullName`` becomes ``fullName``). diff --git a/docs/computed-fields.rst b/docs/computed-fields.rst index 51ae927..182abeb 100644 --- a/docs/computed-fields.rst +++ b/docs/computed-fields.rst @@ -66,6 +66,8 @@ The ``#[ComputedField]`` attribute accepts these parameters: `Entity Types`_. * ``list`` - Optional. ``true`` when the method returns a list of the type, such as an array of strings or of entities. The default is ``false``. +* ``args`` - Optional. The type of each method parameter which is not an ``int``, + ``float``, ``string`` or ``bool``, by its name; see `Arguments`_. * ``description`` - Optional. A description of the computed field for GraphQL schema documentation. * ``name`` - Optional. Override the field name in the GraphQL schema. If not provided, the name is derived from the method name. @@ -146,6 +148,62 @@ Computed fields are: * **Not filterable** - Computed fields cannot be used in database filters since they're calculated in PHP, not at the database level +Arguments +--------- + +A computed field has an argument for each parameter of its method, of the +same name: + +.. code-block:: php + + #[GraphQL\ComputedField(type: 'int')] + public function getTotalRecordings(int|null $year = null): int + { + if ($year === null) { + return $this->recordings->count(); + } + + return $this->recordings->filter( + static fn (Recording $recording): bool => $recording->getYear() === $year, + )->count(); + } + +.. code-block:: graphql + + { artist { edges { node { name totalRecordings } } } } + { artist { edges { node { name totalRecordings(year: 2002) } } } } + +GraphQL arguments are named, so the argument is ``year``, not a position. + +* An ``int``, ``float``, ``string`` or ``bool`` parameter is an ``Int``, + ``Float``, ``String`` or ``Boolean`` argument. Give the type of any other + parameter, by its name, in the attribute's ``args``, as a type registered in + the TypeContainer: + + .. code-block:: php + + #[GraphQL\ComputedField(type: 'boolean', args: ['date' => 'date_immutable'])] + public function getFoundedBefore(DateTimeImmutable $date): bool + +* A parameter which does not allow null is a required, non-null argument, + unless it has a default value. +* A default value is the argument's default, and must be an ``int``, + ``float``, ``string`` or ``bool``. An argument which is not given, and has + no default, is null. +* A variadic parameter, or one passed by reference, cannot be an argument. + +These are checked when the metadata is built, and throw +``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata``. + +Each set of arguments has its own value, so aliases of a field with +different arguments, such as ``a: totalRecordings(year: 2002) +b: totalRecordings(year: 2003)``, each have theirs. A computed field with +arguments has no one value, so the hydrator's ``extract()`` leaves it out. + +The method is called for each entity a query returns. A method which +queries the database, rather than reading the entity, runs a query for each +of them. + Entity Types ------------ diff --git a/docs/versions.rst b/docs/versions.rst index c1d6933..a5f8b28 100644 --- a/docs/versions.rst +++ b/docs/versions.rst @@ -57,7 +57,8 @@ ORM versions do not support ``doctrine/persistence`` 4. entities - Hydrator strategies receive the name of the field being extracted; ``ToString`` hydrator strategy -- A computed field may be of an entity type, or a list of any type +- A computed field may be of an entity type, or a list of any type, and has an argument for + each parameter of its method - Metadata is read as typed value objects, and cached metadata carries its format version and the config it was built with - Configuration mistakes, such as a misplaced attribute, an invalid name or a field diff --git a/src/Attribute/ComputedField.php b/src/Attribute/ComputedField.php index 5dbbdee..8ba2d86 100644 --- a/src/Attribute/ComputedField.php +++ b/src/Attribute/ComputedField.php @@ -31,12 +31,14 @@ final class ComputedField { /** - * @param string $type A type registered in the TypeContainer, or the class of an entity - * exposed in the group, whose type the field is - * @param string $group GraphQL schema group (default: 'default') - * @param string|null $name Field name in GraphQL schema. If null, derived from method name. - * @param string|null $description Field description for GraphQL schema - * @param bool $list Whether the method returns a list of the type + * @param string $type A type registered in the TypeContainer, or the class of an entity + * exposed in the group, whose type the field is + * @param string $group GraphQL schema group (default: 'default') + * @param string|null $name Field name in GraphQL schema. If null, derived from method name. + * @param string|null $description Field description for GraphQL schema + * @param bool $list Whether the method returns a list of the type + * @param array $args The registered type of each method parameter which is + * not an int, float, string or bool, by its name */ public function __construct( private readonly string $type, @@ -44,6 +46,7 @@ public function __construct( private readonly string|null $name = null, private readonly string|null $description = null, private readonly bool $list = false, + private readonly array $args = [], ) { } @@ -71,4 +74,10 @@ public function getList(): bool { return $this->list; } + + /** @return array */ + public function getArgs(): array + { + return $this->args; + } } diff --git a/src/Hydrator/DoctrineObjectWithComputed.php b/src/Hydrator/DoctrineObjectWithComputed.php index 7931f5a..5039610 100644 --- a/src/Hydrator/DoctrineObjectWithComputed.php +++ b/src/Hydrator/DoctrineObjectWithComputed.php @@ -32,19 +32,34 @@ final class DoctrineObjectWithComputed extends DoctrineObject /** * Map of computed field names to extraction callables * - * @var array + * @var array): mixed> */ private array $computedFields = []; + /** + * The names of the computed fields which have arguments + * + * @var array + */ + private array $computedFieldsWithArguments = []; + /** * Register a computed field for extraction * - * @param string $fieldName The GraphQL field name - * @param callable $extractor Callable that accepts the entity and returns the field value + * @param string $fieldName The GraphQL field name + * @param callable(object, array): mixed $extractor Callable that accepts the entity and the + * field's arguments and returns its value + * @param bool $hasArguments Whether the field has arguments */ - public function addComputedField(string $fieldName, callable $extractor): void + public function addComputedField(string $fieldName, callable $extractor, bool $hasArguments = false): void { $this->computedFields[$fieldName] = $extractor; + + if (! $hasArguments) { + return; + } + + $this->computedFieldsWithArguments[$fieldName] = true; } /** @@ -55,6 +70,14 @@ public function hasComputedField(string $fieldName): bool return isset($this->computedFields[$fieldName]); } + /** + * Whether a computed field has arguments, so its value depends on them + */ + public function hasComputedFieldArguments(string $fieldName): bool + { + return isset($this->computedFieldsWithArguments[$fieldName]); + } + /** * Get all registered computed field names * @@ -179,7 +202,9 @@ protected function extractByReference(object $object): array * Extract values from object, including computed fields * * This method extracts the regular Doctrine fields, then adds computed - * field values by calling registered extractors. + * field values by calling registered extractors. A computed field with + * arguments has a value for each set of them, not one value, so it is + * left out. * * @return array */ @@ -189,6 +214,10 @@ public function extract(object $object): array $data = $this->extractFields($object); foreach ($this->getComputedFieldNames() as $fieldName) { + if ($this->hasComputedFieldArguments($fieldName)) { + continue; + } + /** @psalm-suppress MixedAssignment */ $data[$fieldName] = $this->extractComputedField($object, $fieldName); } @@ -210,10 +239,12 @@ public function extractFields(object $object): array /** * Compute a registered computed field + * + * @param array $args The field's arguments */ - public function extractComputedField(object $object, string $fieldName): mixed + public function extractComputedField(object $object, string $fieldName, array $args = []): mixed { - return ($this->computedFields[$fieldName])($object); + return ($this->computedFields[$fieldName])($object, $args); } /** diff --git a/src/Hydrator/HydratorContainer.php b/src/Hydrator/HydratorContainer.php index 9ee6ccf..6be9f56 100644 --- a/src/Hydrator/HydratorContainer.php +++ b/src/Hydrator/HydratorContainer.php @@ -6,7 +6,9 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Container; use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Hydrator as HydratorException; +use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata\ComputedFieldMetadata; use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\EntityTypeContainer; +use Closure; use Doctrine\Laminas\Hydrator\Strategy\CollectionStrategyInterface; use Doctrine\ORM\EntityManager; use GraphQL\Error\Error; @@ -15,6 +17,7 @@ use Override; use ReflectionClass; +use function array_key_exists; use function assert; use function class_implements; use function in_array; @@ -86,13 +89,10 @@ public function get(string $id): mixed // Register computed fields foreach ($entityMetadata->computedFields as $fieldName => $computedFieldMetadata) { - $methodName = $computedFieldMetadata->method; - - // Create extractor closure that calls the entity method - /** @psalm-suppress MixedMethodCall */ $object->addComputedField( $fieldName, - static fn (object $entity): mixed => $entity->$methodName(), + self::computedFieldExtractor($computedFieldMetadata), + $computedFieldMetadata->args !== [], ); } @@ -110,4 +110,32 @@ public function get(string $id): mixed return $hydrator; } + + /** + * A function which calls a computed field's method with the field's + * arguments, by name. An argument which is not given is left to the + * method's default value, or is null when the method has none. + * + * @return Closure(object, array): mixed + */ + private static function computedFieldExtractor(ComputedFieldMetadata $computedFieldMetadata): Closure + { + $methodName = $computedFieldMetadata->method; + $arguments = $computedFieldMetadata->args; + + return static function (object $entity, array $args) use ($methodName, $arguments): mixed { + $named = []; + foreach ($arguments as $name => $argument) { + if (array_key_exists($name, $args)) { + /** @psalm-suppress MixedAssignment An argument may be of any type */ + $named[$name] = $args[$name]; + } elseif ($argument->default === null) { + $named[$name] = null; + } + } + + /** @psalm-suppress MixedMethodCall */ + return $entity->$methodName(...$named); + }; + } } diff --git a/src/Metadata.php b/src/Metadata.php index 4599944..64176c1 100644 --- a/src/Metadata.php +++ b/src/Metadata.php @@ -32,7 +32,7 @@ final class Metadata extends ArrayObject * The version of the exported array's shape. It changes only when the * shape changes, not with every release. */ - public const int FORMAT_VERSION = 2; + public const int FORMAT_VERSION = 3; public const string VERSION_KEY = '__version'; diff --git a/src/Metadata/ArrayReader.php b/src/Metadata/ArrayReader.php index 19ce1f1..14fd94b 100644 --- a/src/Metadata/ArrayReader.php +++ b/src/Metadata/ArrayReader.php @@ -12,6 +12,7 @@ use function is_array; use function is_bool; use function is_int; +use function is_scalar; use function is_string; /** @@ -117,6 +118,18 @@ public function bool(string $key): bool return $value; } + /** @throws MetadataException */ + public function scalar(string $key): int|float|string|bool + { + $value = $this->value($key); + + if (! is_scalar($value)) { + throw $this->invalid($key, 'an int, float, string or bool', $value); + } + + return $value; + } + /** * @return list * diff --git a/src/Metadata/ComputedFieldArgumentMetadata.php b/src/Metadata/ComputedFieldArgumentMetadata.php new file mode 100644 index 0000000..cdde438 --- /dev/null +++ b/src/Metadata/ComputedFieldArgumentMetadata.php @@ -0,0 +1,61 @@ + $array + * + * @throws MetadataException + */ + public static function fromArray(array $array, string $context): self + { + $reader = new ArrayReader($array, $context); + + return new self( + $reader->string('type'), + $reader->bool('nullable'), + $reader->has('default') ? $reader->scalar('default') : null, + ); + } + + /** + * The default is exported only when the argument has one + * + * @return array{type: string, nullable: bool, default?: int|float|string|bool} + */ + public function toArray(): array + { + $array = [ + 'type' => $this->type, + 'nullable' => $this->nullable, + ]; + + if ($this->default !== null) { + $array['default'] = $this->default; + } + + return $array; + } +} diff --git a/src/Metadata/ComputedFieldMetadata.php b/src/Metadata/ComputedFieldMetadata.php index 79aa438..a3c69ac 100644 --- a/src/Metadata/ComputedFieldMetadata.php +++ b/src/Metadata/ComputedFieldMetadata.php @@ -6,17 +6,21 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata as MetadataException; +use function array_map; + /** * The metadata of a computed field exposed with #[ComputedField] */ final readonly class ComputedFieldMetadata { + /** @param array $args The arguments, by name: the method's parameters */ public function __construct( public string $method, public string $type, public string $name, public string|null $description, public bool $list, + public array $args, ) { } @@ -29,16 +33,22 @@ public static function fromArray(array $array, string $context): self { $reader = new ArrayReader($array, $context); + $args = []; + foreach ($reader->arrayOfArrays('args', $context . ' argument ') as $name => $argArray) { + $args[$name] = ComputedFieldArgumentMetadata::fromArray($argArray, $context . ' argument ' . $name); + } + return new self( $reader->string('method'), $reader->string('type'), $reader->string('name'), $reader->nullableString('description'), $reader->bool('list'), + $args, ); } - /** @return array{method: string, type: string, name: string, description: string|null, list: bool} */ + /** @return array{method: string, type: string, name: string, description: string|null, list: bool, args: array} */ public function toArray(): array { return [ @@ -47,6 +57,10 @@ public function toArray(): array 'name' => $this->name, 'description' => $this->description, 'list' => $this->list, + 'args' => array_map( + static fn (ComputedFieldArgumentMetadata $arg): array => $arg->toArray(), + $this->args, + ), ]; } } diff --git a/src/Metadata/EntityMetadata.php b/src/Metadata/EntityMetadata.php index c0b0994..06ac09e 100644 --- a/src/Metadata/EntityMetadata.php +++ b/src/Metadata/EntityMetadata.php @@ -121,8 +121,12 @@ private function assertValidNames(): void } } - foreach (array_keys($this->computedFields) as $name) { + foreach ($this->computedFields as $name => $computedField) { $this->assertValidName($name, 'the name of computed field ' . $name); + + foreach (array_keys($computedField->args) as $argName) { + $this->assertValidName($argName, 'the name of argument ' . $argName . ' of computed field ' . $name); + } } } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 0139bb8..2075cbf 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -16,6 +16,9 @@ use Doctrine\ORM\Mapping\ClassMetadata; use League\Event\EventDispatcher; use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionParameter; use ReflectionProperty; use function array_key_exists; @@ -24,6 +27,7 @@ use function ctype_upper; use function in_array; use function is_array; +use function is_scalar; use function lcfirst; use function str_contains; use function str_replace; @@ -335,6 +339,7 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass 'name' => $fieldName, 'description' => $instance->getDescription(), 'list' => $instance->getList(), + 'args' => $this->buildComputedFieldArguments($reflectionMethod, $instance, $reflectionClass->getName()), ]; /** @psalm-suppress MixedArrayAssignment */ @@ -343,6 +348,96 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass } } + /** + * The arguments of a computed field, one for each parameter of its + * method, in order. An int, float, string or bool parameter is an Int, + * Float, String or Boolean argument; any other is of the type the + * attribute's args give it. A parameter which does not allow null is a + * non-null argument, and its default value is the argument's. + * + * @return array + * + * @throws MetadataException + */ + private function buildComputedFieldArguments( + ReflectionMethod $method, + Attribute\ComputedField $attribute, + string $entityClass, + ): array { + $types = $attribute->getArgs(); + $context = ' of computed field method ' . $method->getName() . ' of entity ' . $entityClass; + + $arguments = []; + foreach ($method->getParameters() as $parameter) { + $name = $parameter->getName(); + $prefix = 'Parameter $' . $name . $context; + + if ($parameter->isVariadic() || $parameter->isPassedByReference()) { + throw new MetadataException( + $prefix . ' is variadic or passed by reference, which an argument cannot be.', + ); + } + + $type = $types[$name] ?? self::argumentType($parameter); + if ($type === null) { + throw new MetadataException( + $prefix . ' is not an int, float, string or bool. Give its type in the args of the ' + . 'ComputedField attribute.', + ); + } + + $argument = ['type' => $type, 'nullable' => $parameter->allowsNull()]; + + // A null default is none: an absent argument is null + /** @psalm-suppress MixedAssignment A default value may be of any type */ + $default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + if ($default !== null) { + if (! is_scalar($default)) { + throw new MetadataException( + $prefix . ' has a default value which is not an int, float, string or bool, which an ' + . 'argument cannot have.', + ); + } + + $argument['default'] = $default; + } + + $arguments[$name] = $argument; + } + + foreach (array_keys($types) as $name) { + if (! isset($arguments[$name])) { + throw new MetadataException( + 'The args of the ComputedField attribute' . $context . ' give the type of ' . $name + . ', which is not a parameter of the method.', + ); + } + } + + return $arguments; + } + + /** + * The registered type of a parameter which is an int, float, string or + * bool, nullable or not + */ + private static function argumentType(ReflectionParameter $parameter): string|null + { + $type = $parameter->getType(); + + if (! $type instanceof ReflectionNamedType) { + return null; + } + + return match ($type->getName()) { + 'int' => 'int', + 'float' => 'float', + 'string' => 'string', + 'bool' => 'boolean', + default => null, + }; + } + /** * The entities an exposed association refers to, and a computed field of * an entity type returns, must be exposed in the group, as their types are diff --git a/src/Resolve/FieldResolver.php b/src/Resolve/FieldResolver.php index 4da2c54..2b91280 100644 --- a/src/Resolve/FieldResolver.php +++ b/src/Resolve/FieldResolver.php @@ -17,8 +17,10 @@ use function array_key_exists; use function assert; +use function is_array; use function is_iterable; use function is_object; +use function serialize; /** * A field resolver that uses the Doctrine Laminas hydrator to extract values @@ -76,19 +78,30 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo $this->extractValues[$source] = $values; } - if (! array_key_exists($info->fieldName, $values)) { + $key = $info->fieldName; + + if (! array_key_exists($key, $values)) { $hydrator = $this->getHydrator($source); if ($hydrator instanceof DoctrineObjectWithComputed && $hydrator->hasComputedField($info->fieldName)) { - /** @psalm-suppress MixedAssignment */ - $values[$info->fieldName] = $hydrator->extractComputedField($source, $info->fieldName); - $this->extractValues[$source] = $values; + // A computed field with arguments has a value for each set of + // them, as aliases of the field may give different arguments + $args = is_array($args) ? $args : []; + if ($hydrator->hasComputedFieldArguments($info->fieldName)) { + $key .= '(' . serialize($args) . ')'; + } + + if (! array_key_exists($key, $values)) { + /** @psalm-suppress MixedAssignment */ + $values[$key] = $hydrator->extractComputedField($source, $info->fieldName, $args); + $this->extractValues[$source] = $values; + } } } // A field's value may be of any type /** @psalm-suppress MixedAssignment */ - $value = $values[$info->fieldName] ?? null; + $value = $values[$key] ?? null; if (! $this->config->getBatchAssociations()) { return $value; diff --git a/src/Type/Entity/Entity.php b/src/Type/Entity/Entity.php index ff9bf85..08e5b1d 100644 --- a/src/Type/Entity/Entity.php +++ b/src/Type/Entity/Entity.php @@ -23,6 +23,7 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; +use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\NullableType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; @@ -441,25 +442,62 @@ protected function addComputedFields(): array $this->typeContainer->get($computedFieldMetadata->type), ), 'description' => $computedFieldMetadata->description, - ]; + ] + $this->computedFieldArgs($fieldName, $computedFieldMetadata); continue; } - $fields[$fieldName] = function () use ($computedFieldMetadata): array { + $fields[$fieldName] = function () use ($fieldName, $computedFieldMetadata): array { $entity = $this->entityTypeContainer->get($computedFieldMetadata->type); // The computed field's description, else the entity's return [ 'type' => self::computedFieldType($computedFieldMetadata, $entity->getObjectType()), 'description' => $computedFieldMetadata->description ?? $entity->getDescription(), - ]; + ] + $this->computedFieldArgs($fieldName, $computedFieldMetadata); }; } return $fields; } + /** + * The args of a computed field, as a field's config gives them, or no key + * for a computed field without arguments + * + * @return array{args?: array} + * + * @throws MetadataException + */ + private function computedFieldArgs(string $fieldName, ComputedFieldMetadata $computedFieldMetadata): array + { + if ($computedFieldMetadata->args === []) { + return []; + } + + $args = []; + foreach ($computedFieldMetadata->args as $name => $argument) { + $type = $this->typeContainer->get($argument->type); + + if (! $type instanceof InputType) { + throw new MetadataException( + 'Argument ' . $name . ' of computed field ' . $fieldName . ' of entity ' . $this->getEntityClass() + . ' is of type ' . $type->toString() . ', which cannot be input.', + ); + } + + $args[$name] = ['type' => $argument->nullable ? $type : self::nonNull($type)]; + + if ($argument->default === null) { + continue; + } + + $args[$name]['defaultValue'] = $argument->default; + } + + return ['args' => $args]; + } + /** * The type of a computed field: its type, or a list of it */ diff --git a/test/Entity/ComputedArgsArtist.php b/test/Entity/ComputedArgsArtist.php new file mode 100644 index 0000000..fe8f71b --- /dev/null +++ b/test/Entity/ComputedArgsArtist.php @@ -0,0 +1,127 @@ + */ + #[ORM\OneToMany(targetEntity: ComputedArgsRecording::class, mappedBy: 'artist')] + #[ORM\OrderBy(['id' => 'ASC'])] + private Collection $recordings; + + public function __construct( + #[GraphQL\Field(group: 'ComputedArgs')] + #[ORM\Column(type: 'string')] + private string $name, + #[ORM\Column(type: 'date_immutable')] + private DateTimeImmutable $founded, + ) { + $this->recordings = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + /** @return Collection */ + public function getRecordings(): Collection + { + return $this->recordings; + } + + /** An optional argument with no default: every recording, or a year's */ + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgs')] + public function getTotalRecordings(int|null $year = null): int + { + if ($year === null) { + return $this->recordings->count(); + } + + return $this->recordings->filter( + static fn (ComputedArgsRecording $recording): bool => $recording->getYear() === $year, + )->count(); + } + + /** A required argument */ + #[GraphQL\ComputedField(type: 'string', group: 'ComputedArgs')] + public function getGreeting(string $salutation): string + { + return $salutation . ', ' . $this->name; + } + + /** Arguments with default values */ + #[GraphQL\ComputedField(type: 'string', group: 'ComputedArgs')] + public function getRepeatedName(int $times = 2, string $separator = ' '): string + { + return implode($separator, array_fill(0, $times, $this->name)); + } + + #[GraphQL\ComputedField(type: 'float', group: 'ComputedArgs')] + public function getScaled(float $factor = 1.5): float + { + return $this->recordings->count() * $factor; + } + + #[GraphQL\ComputedField(type: 'string', group: 'ComputedArgs')] + public function getShout(bool $loud = false): string + { + return $loud ? strtoupper($this->name) : $this->name; + } + + /** + * A list of entities, with an argument + * + * @return list + */ + #[GraphQL\ComputedField(type: ComputedArgsRecording::class, group: 'ComputedArgs', list: true)] + public function getRecordingsSince(int $year): array + { + return array_values($this->recordings->filter( + static fn (ComputedArgsRecording $recording): bool => $recording->getYear() >= $year, + )->toArray()); + } + + /** An argument of a type given in the attribute's args */ + #[GraphQL\ComputedField(type: 'boolean', group: 'ComputedArgs', args: ['date' => 'date_immutable'])] + public function getFoundedBefore(DateTimeImmutable $date): bool + { + return $this->founded < $date; + } + + /** No arguments */ + #[GraphQL\ComputedField(type: 'string', group: 'ComputedArgs')] + public function getDisplayName(): string + { + return 'The ' . $this->name; + } +} diff --git a/test/Entity/ComputedArgsInvalid.php b/test/Entity/ComputedArgsInvalid.php new file mode 100644 index 0000000..5d4d1d2 --- /dev/null +++ b/test/Entity/ComputedArgsInvalid.php @@ -0,0 +1,78 @@ +id; + } + + /** @param int[] $values */ + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgsUnsupportedType')] + public function getUnsupportedType(array $values): int + { + return count($values); + } + + #[GraphQL\ComputedField(type: 'string', group: 'ComputedArgsUnionType')] + public function getUnionType(int|string $value): string + { + return (string) $value; + } + + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgsVariadic')] + public function getVariadic(int ...$values): int + { + return count($values); + } + + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgsByReference')] + public function getByReference(int &$value): int + { + return $value; + } + + #[GraphQL\ComputedField(type: 'boolean', group: 'ComputedArgsObjectDefault', args: ['date' => 'date_immutable'])] + public function getObjectDefault(DateTimeImmutable $date = new DateTimeImmutable('2020-01-01')): bool + { + return $date > new DateTimeImmutable('2000-01-01'); + } + + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgsUnknownArg', args: ['other' => 'int'])] + public function getUnknownArg(int $value): int + { + return $value; + } + + #[GraphQL\ComputedField(type: 'int', group: 'ComputedArgsNotInput', args: ['page' => 'pageinfo'])] + public function getNotInput(mixed $page): int + { + return 0; + } +} diff --git a/test/Entity/ComputedArgsRecording.php b/test/Entity/ComputedArgsRecording.php new file mode 100644 index 0000000..b8016df --- /dev/null +++ b/test/Entity/ComputedArgsRecording.php @@ -0,0 +1,54 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getYear(): int + { + return $this->year; + } + + public function getArtist(): ComputedArgsArtist + { + return $this->artist; + } +} diff --git a/test/Feature/ComputedField/ComputedFieldArgumentsTest.php b/test/Feature/ComputedField/ComputedFieldArgumentsTest.php new file mode 100644 index 0000000..82a3f41 --- /dev/null +++ b/test/Feature/ComputedField/ComputedFieldArgumentsTest.php @@ -0,0 +1,378 @@ +getEntityManager(); + + $artist = new ComputedArgsArtist('Phish', new DateTimeImmutable('1983-12-02')); + $entityManager->persist($artist); + + foreach ([['Junta', 2002], ['Lawn Boy', 2002], ['Round Room', 2003], ['Undermind', 2004]] as [$title, $year]) { + $entityManager->persist(new ComputedArgsRecording($title, $year, $artist)); + } + + $entityManager->flush(); + $entityManager->clear(); + } + + private function getDriver(bool $useHydratorCache = false): Driver + { + return new Driver( + $this->getEntityManager(), + new Config(['group' => 'ComputedArgs', 'useHydratorCache' => $useHydratorCache]), + ); + } + + private function getSchema(Driver $driver): Schema + { + return new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => ['artists' => $driver->completeConnection(ComputedArgsArtist::class)], + ]), + ]); + } + + /** + * The fields of the one artist + * + * @return array + */ + private function queryArtist(string $fields, bool $useHydratorCache = false): array + { + $this->getEntityManager()->clear(); + + $result = GraphQL::executeQuery( + $this->getSchema($this->getDriver($useHydratorCache)), + '{ artists { edges { node { ' . $fields . ' } } } }', + )->toArray(); + + $this->assertArrayNotHasKey('errors', $result); + + return $result['data']['artists']['edges'][0]['node']; + } + + public function testSchemaIsValid(): void + { + $this->getSchema($this->getDriver())->assertValid(); + $this->expectNotToPerformAssertions(); + } + + public function testArgumentsAreTheMethodsParameters(): void + { + $artist = $this->getDriver()->type(ComputedArgsArtist::class); + assert($artist instanceof ObjectType); + + // A parameter which allows null is a nullable argument + $year = $artist->getField('totalRecordings')->getArg('year'); + $this->assertSame(Type::int(), $year?->getType()); + $this->assertFalse($year->defaultValueExists()); + + // A parameter which does not is non-null + $this->assertSame('String!', $artist->getField('greeting')->getArg('salutation')?->getType()->toString()); + + // A default value is the argument's + $times = $artist->getField('repeatedName')->getArg('times'); + $this->assertSame('Int!', $times?->getType()->toString()); + $this->assertSame(2, $times->defaultValue); + $this->assertSame(' ', $artist->getField('repeatedName')->getArg('separator')?->defaultValue); + $this->assertSame(1.5, $artist->getField('scaled')->getArg('factor')?->defaultValue); + $this->assertFalse($artist->getField('shout')->getArg('loud')?->defaultValue); + $this->assertSame('Boolean!', $artist->getField('shout')->getArg('loud')->getType()->toString()); + + // A type given in the attribute's args + $this->assertSame('DateImmutable!', $artist->getField('foundedBefore')->getArg('date')?->getType()->toString()); + + // A field of an entity type has arguments too + $this->assertSame('Int!', $artist->getField('recordingsSince')->getArg('year')?->getType()->toString()); + + $this->assertSame([], $artist->getField('displayName')->args); + } + + /** + * The field with and without its optional argument + */ + public function testOptionalArgument(): void + { + $this->assertSame( + ['name' => 'Phish', 'totalRecordings' => 4], + $this->queryArtist('name totalRecordings'), + ); + + $this->assertSame( + ['name' => 'Phish', 'totalRecordings' => 2], + $this->queryArtist('name totalRecordings(year: 2002)'), + ); + } + + /** @return array */ + public static function hydratorCacheProvider(): array + { + return [ + 'hydrator cache' => [true], + 'no hydrator cache' => [false], + ]; + } + + /** + * Aliases of a field with different arguments each have their own value + */ + #[DataProvider('hydratorCacheProvider')] + public function testAliasesWithDifferentArguments(bool $useHydratorCache): void + { + $this->assertSame( + ['a' => 2, 'b' => 1, 'c' => 4, 'd' => 2, 'e' => 0], + $this->queryArtist( + 'a: totalRecordings(year: 2002) b: totalRecordings(year: 2003) c: totalRecordings ' + . 'd: totalRecordings(year: 2002) e: totalRecordings(year: 1999)', + $useHydratorCache, + ), + ); + } + + public function testDefaultValues(): void + { + $this->assertSame( + [ + 'repeatedName' => 'Phish Phish', + 'three' => 'Phish Phish Phish', + 'dashed' => 'Phish-Phish', + 'scaled' => 6.0, + 'doubled' => 8.0, + 'shout' => 'Phish', + 'loud' => 'PHISH', + ], + $this->queryArtist( + 'repeatedName three: repeatedName(times: 3) dashed: repeatedName(separator: "-") ' + . 'scaled doubled: scaled(factor: 2) shout loud: shout(loud: true)', + ), + ); + } + + public function testRequiredArgument(): void + { + $this->assertSame(['greeting' => 'Hello, Phish'], $this->queryArtist('greeting(salutation: "Hello")')); + + $result = GraphQL::executeQuery( + $this->getSchema($this->getDriver()), + '{ artists { edges { node { greeting } } } }', + )->toArray(); + + $this->assertSame( + 'Field "greeting" argument "salutation" of type "String!" is required but not provided.', + $result['errors'][0]['message'], + ); + } + + public function testArgumentsGivenAsVariables(): void + { + $result = GraphQL::executeQuery( + $this->getSchema($this->getDriver()), + 'query ($year: Int) { artists { edges { node { totalRecordings(year: $year) } } } }', + null, + null, + ['year' => 2004], + )->toArray(); + + $this->assertSame(1, $result['data']['artists']['edges'][0]['node']['totalRecordings']); + } + + public function testListOfEntitiesWithAnArgument(): void + { + $this->assertSame( + [['title' => 'Round Room'], ['title' => 'Undermind']], + $this->queryArtist('recordingsSince(year: 2003) { title }')['recordingsSince'], + ); + } + + public function testArgumentOfAGivenType(): void + { + $this->assertSame( + ['before' => true, 'after' => false], + $this->queryArtist( + 'before: foundedBefore(date: "1990-01-01") after: foundedBefore(date: "1980-01-01")', + ), + ); + } + + /** + * A computed field with arguments has no one value, so the hydrator's + * extract() leaves it out + */ + public function testExtractLeavesOutFieldsWithArguments(): void + { + $driver = $this->getDriver(); + $artist = $this->getEntityManager()->getRepository(ComputedArgsArtist::class)->findOneBy([]); + assert($artist instanceof ComputedArgsArtist); + + $extracted = $driver->get(EntityTypeContainer::class)->get(ComputedArgsArtist::class) + ->getHydrator()->extract($artist); + + $this->assertSame('The Phish', $extracted['displayName']); + $this->assertArrayNotHasKey('totalRecordings', $extracted); + $this->assertArrayNotHasKey('greeting', $extracted); + } + + public function testMetadataRecordsTheArguments(): void + { + $metadata = $this->getDriver()->get(Metadata::class)->toArray(); + $computed = $metadata[ComputedArgsArtist::class]['computedFields']; + + $this->assertSame(['year' => ['type' => 'int', 'nullable' => true]], $computed['totalRecordings']['args']); + $this->assertSame( + [ + 'times' => ['type' => 'int', 'nullable' => false, 'default' => 2], + 'separator' => ['type' => 'string', 'nullable' => false, 'default' => ' '], + ], + $computed['repeatedName']['args'], + ); + $this->assertSame(['date' => ['type' => 'date_immutable', 'nullable' => false]], $computed['foundedBefore']['args']); + $this->assertSame([], $computed['displayName']['args']); + + // Cached metadata builds the same types + $cached = new Driver($this->getEntityManager(), new Config(['group' => 'ComputedArgs']), $metadata); + $artist = $cached->type(ComputedArgsArtist::class); + assert($artist instanceof ObjectType); + $this->assertSame(2, $artist->getField('repeatedName')->getArg('times')?->defaultValue); + } + + public function testDefaultInTheMetadataMustBeScalar(): void + { + $driver = $this->getDriver(); + + $driver->get(EventDispatcher::class)->subscribeTo( + 'metadata.build', + static function (MetadataEvent $event): void { + $metadata = $event->getMetadata(); + $artist = $metadata[ComputedArgsArtist::class]; + assert(is_array($artist)); + + $artist['computedFields']['repeatedName']['args']['times']['default'] = [2]; + $metadata[ComputedArgsArtist::class] = $artist; + }, + ); + + $this->expectException(MetadataException::class); + $this->expectExceptionMessage( + 'Metadata for entity ' . ComputedArgsArtist::class . ' computed field repeatedName argument times key ' + . 'default must be an int, float, string or bool, array given.', + ); + + $driver->type(ComputedArgsArtist::class); + } + + /** + * An argument's name must be a valid GraphQL name. A parameter may be + * named so it is not, such as $__value, as may an argument a listener adds. + */ + public function testInvalidArgumentNameIsRejected(): void + { + $driver = $this->getDriver(); + + $driver->get(EventDispatcher::class)->subscribeTo( + 'metadata.build', + static function (MetadataEvent $event): void { + $metadata = $event->getMetadata(); + $artist = $metadata[ComputedArgsArtist::class]; + assert(is_array($artist)); + + $artist['computedFields']['greeting']['args'] = ['__value' => ['type' => 'string', 'nullable' => false]]; + $metadata[ComputedArgsArtist::class] = $artist; + }, + ); + + $this->expectException(MetadataException::class); + $this->expectExceptionMessage( + 'Metadata for entity ' . ComputedArgsArtist::class . ': the name of argument __value of computed field ' + . 'greeting, "__value", is not a valid GraphQL name.', + ); + + $driver->type(ComputedArgsArtist::class); + } + + /** @return array */ + public static function invalidProvider(): array + { + $method = static fn (string $parameter, string $method): string => 'Parameter $' . $parameter + . ' of computed field method ' . $method . ' of entity ' . ComputedArgsInvalid::class; + + return [ + 'unsupported type' => [ + 'ComputedArgsUnsupportedType', + $method('values', 'getUnsupportedType') . ' is not an int, float, string or bool. Give its type in ' + . 'the args of the ComputedField attribute.', + ], + 'union type' => [ + 'ComputedArgsUnionType', + $method('value', 'getUnionType') . ' is not an int, float, string or bool. Give its type in the ' + . 'args of the ComputedField attribute.', + ], + 'variadic' => [ + 'ComputedArgsVariadic', + $method('values', 'getVariadic') . ' is variadic or passed by reference, which an argument cannot be.', + ], + 'by reference' => [ + 'ComputedArgsByReference', + $method('value', 'getByReference') . ' is variadic or passed by reference, which an argument cannot be.', + ], + 'object default' => [ + 'ComputedArgsObjectDefault', + $method('date', 'getObjectDefault') . ' has a default value which is not an int, float, string or ' + . 'bool, which an argument cannot have.', + ], + 'unknown argument' => [ + 'ComputedArgsUnknownArg', + 'The args of the ComputedField attribute of computed field method getUnknownArg of entity ' + . ComputedArgsInvalid::class . ' give the type of other, which is not a parameter of the method.', + ], + 'not an input type' => [ + 'ComputedArgsNotInput', + 'Argument page of computed field notInput of entity ' . ComputedArgsInvalid::class + . ' is of type PageInfo, which cannot be input.', + ], + ]; + } + + #[DataProvider('invalidProvider')] + public function testParameterWhichCannotBeAnArgumentIsAnError(string $group, string $message): void + { + $driver = new Driver($this->getEntityManager(), new Config(['group' => $group])); + + $this->expectException(MetadataException::class); + $this->expectExceptionMessage($message); + + $driver->type(ComputedArgsInvalid::class); + } +} diff --git a/test/Feature/Hydrator/ExtractionMapTest.php b/test/Feature/Hydrator/ExtractionMapTest.php index 5f0c217..2392314 100644 --- a/test/Feature/Hydrator/ExtractionMapTest.php +++ b/test/Feature/Hydrator/ExtractionMapTest.php @@ -184,7 +184,7 @@ static function (array $performance): array { static function (array $performance): array { $performance['fields']['venue']['alias'] = 'where'; $performance['computedFields'] = [ - 'where' => ['method' => 'getCity', 'type' => 'string', 'name' => 'where', 'description' => null, 'list' => false], + 'where' => ['method' => 'getCity', 'type' => 'string', 'name' => 'where', 'description' => null, 'list' => false, 'args' => []], ]; return $performance; diff --git a/test/Feature/Metadata/EntityMetadataTest.php b/test/Feature/Metadata/EntityMetadataTest.php index 257f6c8..f820056 100644 --- a/test/Feature/Metadata/EntityMetadataTest.php +++ b/test/Feature/Metadata/EntityMetadataTest.php @@ -351,7 +351,7 @@ static function (array $metadata): array { 'computed field' => [ static function (array $metadata): array { $metadata['computedFields'] = [ - 'full-city' => ['method' => 'getCity', 'type' => 'string', 'name' => 'full-city', 'description' => null, 'list' => false], + 'full-city' => ['method' => 'getCity', 'type' => 'string', 'name' => 'full-city', 'description' => null, 'list' => false, 'args' => []], ]; return $metadata;