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. 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. An `expression` (DQL of the value) makes it filterable and sortable: every alias in it is a `{placeholder}` (`{entity}` is the root, others are renamed per use by `Filter\ComputedFieldExpression`), and `{:name}` is an argument, taken from the filter's `args`. `MetadataFactory` parses each expression with `Query::getAST()` (no database) after the `metadata.build` event, and adds to its `excludeFilters` the filters DQL rejects for it (a subquery takes no IN, IS NULL or LIKE); `between` is applied as two comparisons. A sort selects the expression `AS HIDDEN` and orders by it; the batch id-pair query re-adds hidden selects after replacing the select
- `#[ComputedField]` - Exposes derived values from entity methods (placed on public methods), or from methods of the entity's repository (its `repositoryClass`, read by `MetadataFactory` as the entity's methods are), which are given the entity first and called on `EntityManager::getRepository()`. With `batch: true` a repository method is given a `Collection` of entities keyed by the database value of their identifier and returns their values keyed the same way; `Resolve\ComputedFieldBatchLoader` groups entities by exposed class, field and arguments into a `ComputedFieldBatch` and calls it once per group (1000 entities a call). Doctrine inherits a repository only from a mapped superclass, so an exposed subclass missing a repository field of its exposed parent throws. 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. An `expression` (DQL of the value) makes it filterable and sortable: every alias in it is a `{placeholder}` (`{entity}` is the root, others are renamed per use by `Filter\ComputedFieldExpression`), and `{:name}` is an argument, taken from the filter's `args`. `MetadataFactory` parses each expression with `Query::getAST()` (no database) after the `metadata.build` event, and adds to its `excludeFilters` the filters DQL rejects for it (a subquery takes no IN, IS NULL or LIKE); `between` is applied as two comparisons. A sort selects the expression `AS HIDDEN` and orders by it; the batch id-pair query re-adds hidden selects after replacing the select
- `#[ExcludeFilters]` - Excludes specific filters

### Event System (PSR-14)
Expand Down
151 changes: 149 additions & 2 deletions docs/computed-fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ The ``#[ComputedField]`` attribute accepts these parameters:
* ``excludeFilters`` - Optional. Filters to exclude for a field with an ``expression``.
* ``includeFilters`` - Optional. The only filters to allow for a field with an
``expression``. It is mutually exclusive with ``excludeFilters``.
* ``batch`` - Optional. ``true`` when a method of the entity's repository is given a
``Collection`` of entities and returns their values; see `Batching`_. The default
is ``false``.

Custom Field Names
------------------
Expand All @@ -102,7 +105,10 @@ You can override this with the ``name`` parameter:
return 'Artist: ' . $this->name;
}

This creates a ``displayName`` field instead of ``fullDisplayName``.
This creates a ``displayName`` field instead of ``fullDisplayName``. Two
computed fields may not have the same name, nor a field's, which throws
``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata`` when the metadata
is built.

Multiple Computed Fields
-------------------------
Expand Down Expand Up @@ -213,7 +219,8 @@ 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.
of them. A method of the entity's `repository <#repositories>`_ may be
batched instead, to run one query for all of them.

Entity Types
------------
Expand Down Expand Up @@ -402,6 +409,146 @@ Costs
table, store the value in a column instead.
* Databases order nulls differently, as they do for a field.

Repositories
------------

An entity has no entity manager, so a value which needs a query, such as a
count of related rows, is better computed by the entity's repository. The
methods of an entity's repository, its ``repositoryClass``, are read for
``#[ComputedField]`` attributes as the entity's own are. The method is given
the entity first:

.. code-block:: php

use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping as ORM;

#[GraphQL\Entity]
#[ORM\Entity(repositoryClass: ArtistRepository::class)]
class Artist
{
}

class ArtistRepository extends EntityRepository
{
#[GraphQL\ComputedField(type: 'string')]
public function getLatestVenue(Artist $artist, int|null $year = null): string|null
{
// A query using $this->getEntityManager()
}
}

Its further parameters are the field's `arguments <#arguments>`_, and every
other parameter of the attribute applies as it does on an entity, an
``expression`` among them. A computed field of the repository may not have the
name of a field, or of a computed field, of the entity.

The repository is the one the entity manager's ``getRepository()`` gives, so a
custom repository factory, such as Symfony's service repositories, is honoured.

When the metadata is built, the method's first parameter must accept the
entity, or it throws ``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata``.

Batching
~~~~~~~~

A repository's method called for each entity still runs a query for each of
them. With ``batch: true``, it is given every entity being resolved, in a
Doctrine ``Collection``, and returns their values, so one query gives them all:

.. code-block:: php

/**
* @param Collection<int|string, Artist> $artists
*
* @return array<int|string, int>
*/
#[GraphQL\ComputedField(type: 'int', batch: true)]
public function getPerformanceCount(Collection $artists, int|null $year = null): array
{
$queryBuilder = $this->getEntityManager()->createQueryBuilder()
->select('IDENTITY(p.artist) AS id', 'COUNT(p.id) AS total')
->from(Performance::class, 'p')
->where('p.artist IN (:artists)')
->groupBy('p.artist')
->setParameter('artists', $artists->getKeys());

// Filter by $year

return array_column($queryBuilder->getQuery()->getScalarResult(), 'total', 'id');
}

* The ``Collection`` is keyed by the database value of each entity's
identifier, which is what a scalar query, such as one selecting
``IDENTITY()`` of an association, returns. For an integer identifier, that
is the integer. Bind the keys, as above, rather than the entities: Doctrine
does not convert an entity's identifier in an ``IN`` list, which matters for
an identifier such as a binary UUID.
* The method returns the values keyed the same way, as an array or a
``Collection``. Anything else is an error of every entity's field. An
entity it gives no value for is null; for a non-null type, that is an error,
which a client sees as "Internal server error".
* The parameter must accept any ``Collection``: type it ``Collection``, or
wider, such as ``iterable``. The library passes an ``ArrayCollection``, but
promises only a ``Collection``, so ``ArrayCollection`` and ``array`` are
rejected when the metadata is built.
* The entities waiting for the field with the same arguments, at any depth of
the query, are given to one call, up to 1,000 entities a call. Aliases with
different arguments are separate calls.
* An entity whose identifier is composite, or an association, has no single
key, so ``batch: true`` throws when the metadata is built. So does
``batch: true`` on a method of the entity.
* With the ``batchAssociations`` config off, the method is given each entity
alone.
* The hydrator's ``extract()`` leaves a batched field out, as it would be a
query for each entity extracted.

A batched field may be of an `entity type <#entity-types>`_, and a ``list``.
The method returns, keyed by the identifier of the entity it is given, an
entity of the type, or for a list, an array or ``Collection`` of them. Select
each related entity with the identifier it belongs to, then group them:

.. code-block:: php

#[GraphQL\ComputedField(type: Recording::class, list: true, batch: true)]
public function getLiveRecordings(Collection $artists): array
{
$rows = $this->getEntityManager()->createQuery(
'SELECT r, IDENTITY(r.artist) AS artistId FROM ' . Recording::class . ' r'
. ' WHERE r.artist IN (:artists) AND r.live = true',
)->setParameter('artists', $artists->getKeys())->getResult();

$recordings = [];
foreach ($rows as $row) {
$recordings[$row['artistId']][] = $row[0];
}

return $recordings;
}

A list is not a connection: it has no pagination, filters or limit, so the
method must bound it.

An ``expression`` and a batched method do not run together. The expression
only filters and sorts: it is written for one entity and evaluated by the
database for each row of the connection's own query. The method gives the
values, once the page is fetched. So the value is written twice, in two
shapes, which must agree.

Inheritance
~~~~~~~~~~~

Each entity class's own repository is read. Doctrine gives an entity the
repository of a parent only when the parent is a mapped superclass, not when
it is an entity. A row of an exposed subclass is resolved by the subclass's
fields, even where its parent's type is queried, so an exposed subclass whose
repository lacks a computed field of its exposed parent's repository throws
``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata`` when the metadata
is built. Give the subclass a repository which extends its parent's. A row of
a subclass which is not exposed is resolved as its exposed parent, by the
parent's repository.

Hydrator Strategies
-------------------

Expand Down
3 changes: 3 additions & 0 deletions docs/versions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ ORM versions do not support ``doctrine/persistence`` 4.
by it; see `computed fields <computed-fields.html#filters-and-sorting>`_
- 14.1: a filter's ``_or`` matches any of its branches, limited by the ``filterDepth``
and ``filterConditions`` config; see `or <queries.html#or>`_
- 14.1: a computed field may be a method of the entity's repository, and one given a
``Collection`` of entities computes all their values with one query; see
`repositories <computed-fields.html#repositories>`_
- 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
14 changes: 14 additions & 0 deletions src/Attribute/ComputedField.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
* given a unique name each time the expression is used. {:name} is the
* method parameter $name, whose value is given by the filter's args.
*
* A computed field may be on a method of the entity's repository, whose first
* parameter is the entity. With batch, the first parameter is a Collection of
* entities, keyed by the database value of their identifier, and the method
* returns the values keyed the same way, so one query gives the values of
* every entity being resolved.
*
* @example
* ```php
* #[ComputedField(
Expand Down Expand Up @@ -51,6 +57,8 @@ final class ComputedField
* expression
* @param array<Filters|string> $includeFilters Filters cases or their values, for a field with an
* expression
* @param bool $batch Whether a method of a repository is given a Collection
* of entities and returns their values, keyed by identifier
*/
public function __construct(
private readonly string $type,
Expand All @@ -62,6 +70,7 @@ public function __construct(
private readonly string|null $expression = null,
private readonly array $excludeFilters = [],
private readonly array $includeFilters = [],
private readonly bool $batch = false,
) {
}

Expand Down Expand Up @@ -100,4 +109,9 @@ public function getExpression(): string|null
{
return $this->expression;
}

public function getBatch(): bool
{
return $this->batch;
}
}
49 changes: 40 additions & 9 deletions src/Hydrator/DoctrineObjectWithComputed.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Hydrator as HydratorException;
use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy\Strategy;
use ApiSkeletons\Doctrine\ORM\GraphQL\Trait\FindPropertyInHierarchy;
use Closure;
use Doctrine\Common\Collections\Collection;
use Doctrine\Laminas\Hydrator\DoctrineObject;
use Laminas\Hydrator\Filter\FilterProviderInterface;
use LogicException;
Expand Down Expand Up @@ -44,17 +46,34 @@ final class DoctrineObjectWithComputed extends DoctrineObject
private array $computedFieldsWithArguments = [];

/**
* Register a computed field for extraction
* The batch extractors of the batched computed fields, by name
*
* @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
* @var array<string, Closure(Collection<int|string, object>, array<array-key, mixed>): mixed>
*/
public function addComputedField(string $fieldName, callable $extractor, bool $hasArguments = false): void
{
private array $batchExtractors = [];

/**
* Register a computed field for extraction: the GraphQL field name, a
* callable that accepts the entity and the field's arguments and returns
* its value, and whether the field has arguments. A batched field also
* has a batch extractor, a Closure that accepts a Collection of entities
* and the field's arguments and returns their values, keyed by identifier.
*
* @param callable(object, array<array-key, mixed>): mixed $extractor
* @param (Closure(Collection<int|string, object>, array<array-key, mixed>): mixed)|null $batchExtractor
*/
public function addComputedField(
string $fieldName,
callable $extractor,
bool $hasArguments = false,
Closure|null $batchExtractor = null,
): void {
$this->computedFields[$fieldName] = $extractor;

if ($batchExtractor !== null) {
$this->batchExtractors[$fieldName] = $batchExtractor;
}

if (! $hasArguments) {
return;
}
Expand All @@ -78,6 +97,17 @@ public function hasComputedFieldArguments(string $fieldName): bool
return isset($this->computedFieldsWithArguments[$fieldName]);
}

/**
* The batch extractor of a batched computed field, which is given a
* Collection of entities, or null for a field which is not batched
*
* @return (Closure(Collection<int|string, object>, array<array-key, mixed>): mixed)|null
*/
public function getBatchExtractor(string $fieldName): Closure|null
{
return $this->batchExtractors[$fieldName] ?? null;
}

/**
* Get all registered computed field names
*
Expand Down Expand Up @@ -204,7 +234,8 @@ protected function extractByReference(object $object): array
* This method extracts the regular Doctrine fields, then adds computed
* 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.
* left out. A batched computed field would be a query for each entity
* extracted, so it is left out too.
*
* @return array<array-key, mixed>
*/
Expand All @@ -214,7 +245,7 @@ public function extract(object $object): array
$data = $this->extractFields($object);

foreach ($this->getComputedFieldNames() as $fieldName) {
if ($this->hasComputedFieldArguments($fieldName)) {
if ($this->hasComputedFieldArguments($fieldName) || isset($this->batchExtractors[$fieldName])) {
continue;
}

Expand Down
Loading
Loading