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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/attributes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<computed-fields.html#arguments>`_.
* ``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``).
Expand Down
58 changes: 58 additions & 0 deletions docs/computed-fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
------------

Expand Down
3 changes: 2 additions & 1 deletion docs/versions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions src/Attribute/ComputedField.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,22 @@
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<string, string> $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,
private readonly string $group = 'default',
private readonly string|null $name = null,
private readonly string|null $description = null,
private readonly bool $list = false,
private readonly array $args = [],
) {
}

Expand Down Expand Up @@ -71,4 +74,10 @@ public function getList(): bool
{
return $this->list;
}

/** @return array<string, string> */
public function getArgs(): array
{
return $this->args;
}
}
45 changes: 38 additions & 7 deletions src/Hydrator/DoctrineObjectWithComputed.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,34 @@ final class DoctrineObjectWithComputed extends DoctrineObject
/**
* Map of computed field names to extraction callables
*
* @var array<string, callable>
* @var array<string, callable(object, array<array-key, mixed>): mixed>
*/
private array $computedFields = [];

/**
* The names of the computed fields which have arguments
*
* @var array<string, true>
*/
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<array-key, mixed>): 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;
}

/**
Expand All @@ -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
*
Expand Down Expand Up @@ -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<array-key, mixed>
*/
Expand All @@ -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);
}
Expand All @@ -210,10 +239,12 @@ public function extractFields(object $object): array

/**
* Compute a registered computed field
*
* @param array<array-key, mixed> $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);
}

/**
Expand Down
38 changes: 33 additions & 5 deletions src/Hydrator/HydratorContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +17,7 @@
use Override;
use ReflectionClass;

use function array_key_exists;
use function assert;
use function class_implements;
use function in_array;
Expand Down Expand Up @@ -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 !== [],
);
}

Expand All @@ -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<array-key, mixed>): 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);
};
}
}
2 changes: 1 addition & 1 deletion src/Metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
13 changes: 13 additions & 0 deletions src/Metadata/ArrayReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use function is_array;
use function is_bool;
use function is_int;
use function is_scalar;
use function is_string;

/**
Expand Down Expand Up @@ -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<string>
*
Expand Down
61 changes: 61 additions & 0 deletions src/Metadata/ComputedFieldArgumentMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace ApiSkeletons\Doctrine\ORM\GraphQL\Metadata;

use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata as MetadataException;

/**
* The metadata of an argument of a computed field: a parameter of its method
*/
final readonly class ComputedFieldArgumentMetadata
{
/**
* @param string $type The registered type of the argument
* @param bool $nullable Whether the argument may be null
* @param int|float|string|bool|null $default The default value, or null for none. A null
* default is none, as an absent argument is null.
*/
public function __construct(
public string $type,
public bool $nullable,
public int|float|string|bool|null $default,
) {
}

/**
* @param array<array-key, mixed> $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;
}
}
Loading
Loading