Computed Fields

Computed fields allow you to expose derived values from entity methods in your GraphQL schema without storing them in the database. Common use cases include full names, formatted values, calculations, and other business logic.

Using the ComputedField Attribute

The simplest way to add a computed field is with the #[ComputedField] attribute. This attribute is placed on a public method in your entity.

Basic Example

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

#[GraphQL\Entity]
class Artist
{
    #[GraphQL\Field]
    private string $firstName;

    #[GraphQL\Field]
    private string $lastName;

    #[GraphQL\ComputedField(
        type: 'string',
        description: 'Full name of the artist'
    )]
    public function getFullName(): string
    {
        return $this->firstName . ' ' . $this->lastName;
    }
}

This creates a fullName field in your GraphQL schema that calls the getFullName() method when queried. The field name is automatically derived from the method name by removing the get prefix.

query {
  artists {
    edges {
      node {
        firstName
        lastName
        fullName
      }
    }
  }
}

ComputedField Parameters

The #[ComputedField] attribute accepts these parameters:

  • type - Required. The GraphQL type name (e.g., 'string', 'int', 'boolean'), which must match a registered type in the TypeContainer, or the class of an entity; see 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.

  • group - Optional. The attribute group (default: 'default').

  • expression - Optional. The DQL expression of the field’s value, by which it is filtered and sorted; see Filters and Sorting.

  • 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

By default, field names are derived from method names:

  • getFullName() becomes fullName

  • isActive() stays isActive (for boolean methods)

  • Other methods use the method name as-is, including a method such as getaway() whose name only begins with get

You can override this with the name parameter:

#[GraphQL\ComputedField(
    type: 'string',
    name: 'displayName',
    description: 'Display name for UI'
)]
public function getFullDisplayName(): string
{
    return 'Artist: ' . $this->name;
}

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

You can add multiple computed fields to the same entity:

#[GraphQL\Entity]
class User
{
    #[GraphQL\Field]
    private string $name;

    #[GraphQL\Field]
    private string $email;

    #[GraphQL\ComputedField(type: 'string', description: 'Full name')]
    public function getFullName(): string
    {
        return $this->name . ' (' . $this->email . ')';
    }

    #[GraphQL\ComputedField(type: 'string', description: 'Email domain')]
    public function getEmailDomain(): string
    {
        return substr($this->email, strpos($this->email, '@') + 1);
    }

    #[GraphQL\ComputedField(type: 'boolean', description: 'Is user active')]
    public function isActive(): bool
    {
        return $this->password !== '';
    }
}

How Computed Fields Work

Computed fields are:

  • Lazy evaluated - The method is called only when the field is requested in a GraphQL query, and once for each entity however many times the query requests it. A field with `arguments`_ is computed once for each entity and set of arguments.

  • Integrated with the hydrator - The hydrator’s extract() returns computed values with the regular fields, except those of fields with arguments, which have no one value

  • Cached - If you enable useHydratorCache, a computed value is cached with the entity’s other values for as long as the entity exists

  • Filtered by an expression - A computed field’s value is calculated in PHP, so it is filtered and sorted only when it has an expression, its value in DQL; see Filters and Sorting

Arguments

A computed field has an argument for each parameter of its method, of the same name:

#[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();
}
{ 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:

    #[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. When the type is built, an argument’s type must be one which can be input, and a default must be a value of it: '2020-01-01' is not a value of date_immutable, whose values are dates, so a parameter given that type in args may not have it as its default. These throw the same exception.

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. A method of the entity’s repository may be batched instead, to run one query for all of them.

Entity Types

A computed field’s type may be the class of an entity exposed in the driver’s group. The field is then of that entity’s type, and its method returns an entity of the class, or null. With list: true the method returns a list of them, such as an array or a Doctrine Collection.

#[GraphQL\Entity]
class Book
{
    #[GraphQL\ComputedField(type: Author::class)]
    public function getWriter(): Author
    {
        return $this->author;
    }
}

#[GraphQL\Entity]
class Author
{
    #[GraphQL\ComputedField(type: Book::class, list: true)]
    public function getBestSellers(): array
    {
        return array_values($this->books->filter(
            static fn (Book $book): bool => $book->isBestSeller(),
        )->toArray());
    }
}

This exposes a relation which is not mapped as an association, or which is derived from one, or a subclass’s own fields through its parent’s type; see Inheritance. The entity may be of the same class as the field’s own entity, and two entities may have computed fields of each other’s types. An entity of a class which is not exposed in the group throws ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when the metadata is built. The field’s description is its own, else the entity’s.

An entity which is not loaded, such as the target of an unloaded to-one association, is loaded in a batch with the others, as an unloaded to-one association is; so are the entities of a list. The method itself is called for each entity, so a method which reads a collection loads that collection for each entity. A computed field is not a connection: a list has no pagination, filters or totalCount, and it holds what the method returns. For a large collection, expose the association instead, whose rows the database filters and pages.

Filters and Sorting

A computed field’s value is calculated in PHP, after the rows are fetched, but a filter must be applied by the database: the rows of a page, and its totalCount, are counted and limited there. So a computed field is filtered and sorted only when it has an expression: the DQL expression of its value. The field’s filters, sort and sortPriority among them, then apply to the expression as a field’s apply to its column.

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

#[GraphQL\Entity]
class Artist
{
    #[GraphQL\ComputedField(
        type: 'int',
        expression: '(SELECT COUNT({p}.id) FROM App\ORM\Entity\Performance {p} WHERE {p}.artist = {entity})',
    )]
    public function getPerformanceCount(): int
    {
        return $this->performances->count();
    }
}
{
  artists (filter: { performanceCount: { gt: 0, sort: DESC } }) {
    edges { node { name performanceCount } }
  }
}

The method still computes the value a query returns; the expression is used only to filter and sort. They must compute the same value, or a filter will disagree with the value shown, and a sorted list will appear out of order.

Placeholders

Important

Every alias in an expression must be a placeholder, written in braces: {entity} for the field’s own entity, and any other name, such as {p}, for an alias of the expression’s own.

DQL aliases must be unique across a query, subqueries included, and an expression may be used more than once in one query: by two filters, such as { gt: 0, lt: 5 }, or by a filter and a sort. Each placeholder is given a name unique in the query each time the expression is used, so its aliases never collide with each other’s, or with those of a QueryBuilder listener. An alias written plainly, such as p, works until the expression is used twice; the check described below finds it.

A placeholder is any {name} in the expression, including one within a string literal.

Arguments

{:name} in an expression is the field’s argument name, bound as a parameter. A filter is applied before any field is resolved, so its arguments cannot be those the field is given where it is selected. The field’s filters take them instead, in args, of the arguments the expression uses:

#[GraphQL\ComputedField(
    type: 'int',
    expression: '(SELECT COUNT({r}.id) FROM App\ORM\Entity\Recording {r} '
        . 'WHERE {r}.artist = {entity} AND {r}.year >= {:year})',
)]
public function getRecordingCountSince(int $year): int
{
  artists (filter: { recordingCountSince: { args: { year: 2003 }, gt: 0 } }) {
    edges { node { name recordingCountSince(year: 2003) } }
  }
}

An argument not given in args is its default value, or null when it has none. args is required when an argument the expression uses is. The filter’s args and the field’s own arguments are independent: a filter may use 2003 while the field shows 2004.

Which Filters Apply

The filters of a computed field are those of its type, less those excluded by the configuration, by its entity, by the association whose filters they are, and by its own excludeFilters or includeFilters.

DQL compares any expression, but takes a subquery, or an arithmetic expression, for none of IN, IS NULL and LIKE. So the in, notin, isnull, contains, startswith and endswith filters are each excluded for an expression DQL does not take them for, and added to the field’s excludeFilters in the metadata. between is applied as two comparisons, so it applies to every expression. A function, such as UPPER({entity}.name) or SIZE({entity}.performances), takes every filter.

A sort orders by the expression selected as a hidden result variable, as DQL does not order by a subquery. A QueryBuilder listener must not replace the query’s select, as ->select() does, which would remove it.

Checks

Each expression is checked when the metadata is built, after the metadata.build event, and throws ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when:

  • it is not valid DQL, such as for a syntax error, an unknown field or class, or an alias written without a placeholder. Doctrine’s QueryException is the previous exception. The expression is parsed, not run, so no database is needed.

  • it uses {:name} for a name which is not a parameter of the method.

  • its field is a list, or of an entity type, neither of which has a single value to filter or sort by.

  • a field without an expression has excludeFilters or includeFilters.

Metadata read from a cache is not checked again.

Costs

  • A subquery is evaluated for each row the filters consider, and a sort by one orders every row before the page is taken, without an index. For a large 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:

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, 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:

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

#[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

Computed fields do not use hydrator strategies. The value returned by the entity method is used as-is; it is not passed through a strategy and a strategy’s $fieldName argument is never a computed field name. Transform the value inside the entity method instead.

Advanced: Event-Based Computed Fields

For more complex scenarios, such as computed fields that require database queries or external service calls, you can use the EntityDefinition Event.

This approach gives you full control over the field definition and resolver logic. You must attach a listener before defining your GraphQL schema.

Events of this type are named Entity::class . '.definition' and the event name cannot be modified.

use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\EntityDefinition;
use App\ORM\Entity\Artist;
use App\ORM\Entity\Performance;
use Doctrine\ORM\EntityManager;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use League\Event\EventDispatcher;

$driver = new Driver($entityManager);

$driver->get(EventDispatcher::class)->subscribeTo(
    Artist::class . '.definition',
    static function (EntityDefinition $event) use ($driver): void {
        $definition = $event->getDefinition();

        // In order to modify the fields you must resolve the closure
        $fields = $definition['fields']();

        /**
         * Add a computed field to show the count of performances
         * This field will only be computed when it is requested specifically
         * in the query
         */
        $fields['performanceCount'] = [
            'type' => Type::int(),
            'description' => 'The count of performances for an Artist',
            'resolve' => static function (Artist $objectValue, array $args, $context, ResolveInfo $info) use ($driver): int {
                $queryBuilder = $driver->get(EntityManager::class)->createQueryBuilder();
                $queryBuilder
                   ->select('COUNT(performance)')
                   ->from(Performance::class, 'performance')
                   ->andWhere('performance.artist = :artistId')
                   ->setParameter('artistId', $objectValue->getId());

                return (int) $queryBuilder->getQuery()->getSingleScalarResult();
            },
        ];

        // Assign modified fields array to the ArrayObject
        $definition['fields'] = $fields;
    }
);

A query for this computed field:

query ArtistQueryWithComputedField($id: Int!)  {
  artist(id: $id) {
    id
    name
    performanceCount
  }
}

This is documentation for API-Skeletons/doctrine-orm-graphql. Please add your ★ star to the project.