From 280723df441b7f489a3986cd6cdfd9f7c32114e9 Mon Sep 17 00:00:00 2001 From: Tom H Anderson Date: Mon, 5 Oct 2026 21:45:03 -0600 Subject: [PATCH] Computed fields on repositories, batched or not A #[ComputedField] may be on a method of the entity's repository, which is given the entity first and is called on the repository the entity manager gives, so a custom repository factory is honoured. Its further parameters are the field's arguments. With batch: true the method is given a Collection of every entity waiting for the field with the same arguments, keyed by the database value of their identifier, and returns their values keyed the same way, so one query computes them all. - Resolve\ComputedFieldBatchLoader groups entities by exposed class, field and arguments and calls the method once per group, at most 1000 entities a call. A missing value is null; a method returning neither an array nor a Collection is an error of every entity's field. - Batched fields may be of an entity type and a list; with batchAssociations off each entity is given alone; extract() leaves batched fields out. - When the metadata is built: the first parameter must accept the entity, or any Collection for a batch; batch is rejected on an entity's method and for a composite or association identifier. - Doctrine gives an entity its parent's repository only from a mapped superclass, so an exposed subclass lacking a repository field of its exposed parent throws rather than resolving it as null. - Two computed fields of one name throw, rather than one silently replacing the other. - Metadata records repository and batch, left out when unset. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/computed-fields.rst | 151 +++++- docs/versions.rst | 3 + src/Attribute/ComputedField.php | 14 + src/Hydrator/DoctrineObjectWithComputed.php | 49 +- src/Hydrator/HydratorContainer.php | 47 +- src/Metadata/ComputedFieldMetadata.php | 32 +- src/Metadata/MetadataFactory.php | 233 ++++++++- src/Resolve/ComputedFieldBatch.php | 88 ++++ src/Resolve/ComputedFieldBatchLoader.php | 162 ++++++ src/Resolve/FieldResolver.php | 30 +- src/Services.php | 10 + test/Entity/RepositoryAnimal.php | 52 ++ test/Entity/RepositoryArtist.php | 82 +++ test/Entity/RepositoryBird.php | 18 + test/Entity/RepositoryCat.php | 18 + test/Entity/RepositoryCompositeKey.php | 37 ++ test/Entity/RepositoryDerivedKey.php | 29 ++ test/Entity/RepositoryDog.php | 18 + test/Entity/RepositoryInvalid.php | 45 ++ test/Entity/RepositoryLabel.php | 53 ++ test/Entity/RepositoryRecording.php | 55 +++ test/Entity/TypedIdAuthor.php | 5 +- ...RepositoryComputedFieldInheritanceTest.php | 102 ++++ .../RepositoryComputedFieldTest.php | 465 ++++++++++++++++++ .../RepositoryComputedFieldValidationTest.php | 127 +++++ test/Feature/Resolve/TypedIdentifierTest.php | 26 + .../Repository/RepositoryAnimalRepository.php | 40 ++ .../Repository/RepositoryArtistRepository.php | 192 ++++++++ test/Repository/RepositoryCatRepository.php | 20 + test/Repository/RepositoryDogRepository.php | 12 + .../RepositoryInvalidRepository.php | 147 ++++++ test/Repository/RepositoryKeyRepository.php | 29 ++ test/Repository/TypedIdAuthorRepository.php | 53 ++ 34 files changed, 2409 insertions(+), 37 deletions(-) create mode 100644 src/Resolve/ComputedFieldBatch.php create mode 100644 src/Resolve/ComputedFieldBatchLoader.php create mode 100644 test/Entity/RepositoryAnimal.php create mode 100644 test/Entity/RepositoryArtist.php create mode 100644 test/Entity/RepositoryBird.php create mode 100644 test/Entity/RepositoryCat.php create mode 100644 test/Entity/RepositoryCompositeKey.php create mode 100644 test/Entity/RepositoryDerivedKey.php create mode 100644 test/Entity/RepositoryDog.php create mode 100644 test/Entity/RepositoryInvalid.php create mode 100644 test/Entity/RepositoryLabel.php create mode 100644 test/Entity/RepositoryRecording.php create mode 100644 test/Feature/ComputedField/RepositoryComputedFieldInheritanceTest.php create mode 100644 test/Feature/ComputedField/RepositoryComputedFieldTest.php create mode 100644 test/Feature/ComputedField/RepositoryComputedFieldValidationTest.php create mode 100644 test/Repository/RepositoryAnimalRepository.php create mode 100644 test/Repository/RepositoryArtistRepository.php create mode 100644 test/Repository/RepositoryCatRepository.php create mode 100644 test/Repository/RepositoryDogRepository.php create mode 100644 test/Repository/RepositoryInvalidRepository.php create mode 100644 test/Repository/RepositoryKeyRepository.php create mode 100644 test/Repository/TypedIdAuthorRepository.php diff --git a/CLAUDE.md b/CLAUDE.md index 231cc6d..bfde339 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. 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) diff --git a/docs/computed-fields.rst b/docs/computed-fields.rst index 0b2e61b..3e9fbd4 100644 --- a/docs/computed-fields.rst +++ b/docs/computed-fields.rst @@ -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 ------------------ @@ -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 ------------------------- @@ -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 ------------ @@ -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 $artists + * + * @return array + */ + #[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 ------------------- diff --git a/docs/versions.rst b/docs/versions.rst index 8bac4f6..cb8a999 100644 --- a/docs/versions.rst +++ b/docs/versions.rst @@ -63,6 +63,9 @@ ORM versions do not support ``doctrine/persistence`` 4. by it; see `computed fields `_ - 14.1: a filter's ``_or`` matches any of its branches, limited by the ``filterDepth`` and ``filterConditions`` config; see `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 `_ - 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 dd46572..3848fb1 100644 --- a/src/Attribute/ComputedField.php +++ b/src/Attribute/ComputedField.php @@ -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( @@ -51,6 +57,8 @@ final class ComputedField * expression * @param array $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, @@ -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, ) { } @@ -100,4 +109,9 @@ public function getExpression(): string|null { return $this->expression; } + + public function getBatch(): bool + { + return $this->batch; + } } diff --git a/src/Hydrator/DoctrineObjectWithComputed.php b/src/Hydrator/DoctrineObjectWithComputed.php index 5039610..bcd8a07 100644 --- a/src/Hydrator/DoctrineObjectWithComputed.php +++ b/src/Hydrator/DoctrineObjectWithComputed.php @@ -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; @@ -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): 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, array): 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): mixed $extractor + * @param (Closure(Collection, array): 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; } @@ -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, array): mixed)|null + */ + public function getBatchExtractor(string $fieldName): Closure|null + { + return $this->batchExtractors[$fieldName] ?? null; + } + /** * Get all registered computed field names * @@ -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 */ @@ -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; } diff --git a/src/Hydrator/HydratorContainer.php b/src/Hydrator/HydratorContainer.php index 6be9f56..ca0a4ba 100644 --- a/src/Hydrator/HydratorContainer.php +++ b/src/Hydrator/HydratorContainer.php @@ -7,6 +7,7 @@ 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\Resolve\ComputedFieldBatchLoader; use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\EntityTypeContainer; use Closure; use Doctrine\Laminas\Hydrator\Strategy\CollectionStrategyInterface; @@ -31,6 +32,7 @@ final class HydratorContainer extends Container public function __construct( protected readonly EntityManager $entityManager, protected readonly EntityTypeContainer $entityTypeContainer, + protected readonly ComputedFieldBatchLoader $computedFieldBatchLoader, ) { // Register default strategies $this @@ -89,10 +91,32 @@ public function get(string $id): mixed // Register computed fields foreach ($entityMetadata->computedFields as $fieldName => $computedFieldMetadata) { + $extractor = self::computedFieldExtractor( + $computedFieldMetadata, + $computedFieldMetadata->repository ? $entityManager : null, + $entityMetadata->entityClass, + ); + + $batchExtractor = null; + if ($computedFieldMetadata->batch) { + // The method is given a Collection of entities; alone, one + $batchExtractor = $extractor; + $loader = $self->computedFieldBatchLoader; + $entityClass = $entityMetadata->entityClass; + $extractor = static fn (object $entity, array $args): mixed => $loader->loadOne( + $entity, + $entityClass, + $fieldName, + $batchExtractor, + $args, + ); + } + $object->addComputedField( $fieldName, - self::computedFieldExtractor($computedFieldMetadata), + $extractor, $computedFieldMetadata->args !== [], + $batchExtractor, ); } @@ -116,14 +140,24 @@ public function get(string $id): mixed * 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. * + * The method of an entity is called on the entity. The method of a + * repository is called on the repository the entity manager gives, so + * a custom repository factory is honoured, with the entity, or for a + * batched field, a Collection of entities, first. + * + * @param class-string $entityClass + * * @return Closure(object, array): mixed */ - private static function computedFieldExtractor(ComputedFieldMetadata $computedFieldMetadata): Closure - { + private static function computedFieldExtractor( + ComputedFieldMetadata $computedFieldMetadata, + EntityManager|null $entityManager, + string $entityClass, + ): Closure { $methodName = $computedFieldMetadata->method; $arguments = $computedFieldMetadata->args; - return static function (object $entity, array $args) use ($methodName, $arguments): mixed { + return static function (object $entity, array $args) use ($methodName, $arguments, $entityManager, $entityClass): mixed { $named = []; foreach ($arguments as $name => $argument) { if (array_key_exists($name, $args)) { @@ -134,6 +168,11 @@ private static function computedFieldExtractor(ComputedFieldMetadata $computedFi } } + if ($entityManager !== null) { + /** @psalm-suppress MixedMethodCall */ + return $entityManager->getRepository($entityClass)->$methodName($entity, ...$named); + } + /** @psalm-suppress MixedMethodCall */ return $entity->$methodName(...$named); }; diff --git a/src/Metadata/ComputedFieldMetadata.php b/src/Metadata/ComputedFieldMetadata.php index d06f9ee..dd13d81 100644 --- a/src/Metadata/ComputedFieldMetadata.php +++ b/src/Metadata/ComputedFieldMetadata.php @@ -19,6 +19,10 @@ * @param string|null $expression The DQL expression of the value, by * which the field is filtered and sorted * @param list $excludeFilters The filters excluded for the field + * @param bool $repository Whether the method is of the entity's + * repository, given the entity first + * @param bool $batch Whether the repository's method is given + * a Collection of entities */ public function __construct( public string $method, @@ -29,6 +33,8 @@ public function __construct( public array $args, public string|null $expression = null, public array $excludeFilters = [], + public bool $repository = false, + public bool $batch = false, ) { } @@ -46,6 +52,16 @@ public static function fromArray(array $array, string $context): self $args[$name] = ComputedFieldArgumentMetadata::fromArray($argArray, $context . ' argument ' . $name); } + $repository = $reader->has('repository') && $reader->bool('repository'); + $batch = $reader->has('batch') && $reader->bool('batch'); + + if ($batch && ! $repository) { + throw new MetadataException( + 'Metadata for ' . $context . ' is batched but not of a repository. Only a method of a repository ' + . 'is batched.', + ); + } + return new self( $reader->string('method'), $reader->string('type'), @@ -55,14 +71,16 @@ public static function fromArray(array $array, string $context): self $args, $reader->has('expression') ? $reader->string('expression') : null, $reader->has('excludeFilters') ? $reader->stringList('excludeFilters') : [], + $repository, + $batch, ); } /** - * The expression and the excluded filters are exported only when the - * field has them + * Whether the field is of a repository and batched, the expression and + * the excluded filters are exported only when the field has them * - * @return array{method: string, type: string, name: string, description: string|null, list: bool, args: array, expression?: string, excludeFilters?: list} + * @return array{method: string, type: string, name: string, description: string|null, list: bool, args: array, repository?: true, batch?: true, expression?: string, excludeFilters?: list} */ public function toArray(): array { @@ -78,6 +96,14 @@ public function toArray(): array ), ]; + if ($this->repository) { + $array['repository'] = true; + } + + if ($this->batch) { + $array['batch'] = true; + } + if ($this->expression !== null) { $array['expression'] = $this->expression; } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 0501692..63f7c34 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -13,6 +13,7 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy; use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata; use ApiSkeletons\Doctrine\ORM\GraphQL\Trait\FindPropertyInHierarchy; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\QueryException; @@ -22,14 +23,18 @@ use ReflectionNamedType; use ReflectionParameter; use ReflectionProperty; +use ReflectionUnionType; use function array_key_exists; use function array_keys; +use function array_shift; use function array_unique; use function array_values; use function assert; +use function count; use function ctype_upper; use function in_array; +use function is_a; use function is_array; use function is_scalar; use function lcfirst; @@ -101,7 +106,8 @@ public function getMetadata(): Metadata $this->assertPropertyAttributesAreMapped($entityClassMetadata, $reflectionClass); $this->buildMetadataForFields($entityClassMetadata, $reflectionClass); $this->buildMetadataForAssociations($reflectionClass); - $this->buildMetadataForComputedFields($reflectionClass); + $this->buildMetadataForComputedFields($reflectionClass, $reflectionClass->getName()); + $this->buildMetadataForRepositoryComputedFields($entityClassMetadata, $reflectionClass->getName()); } // Fire the metadata.build event @@ -111,6 +117,7 @@ public function getMetadata(): Metadata // After the event, as a listener may add or remove entities $this->assertReferencedEntitiesAreExposed($entityClasses); + $this->assertSubclassesHaveRepositoryFields($entityClasses); $this->assertComputedFieldExpressionsAreValid($entityClasses); return $this->metadata; @@ -291,19 +298,31 @@ private function buildMetadataForAssociations( } /** - * Build the metadata for computed fields in an entity based on ComputedField attributes + * Build the metadata for computed fields in an entity based on ComputedField + * attributes on the methods of a class: the entity, or its repository * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass The entity, or its repository + * @param class-string $entityClass + * @param ClassMetadata $classMetadata The entity's class metadata, for a repository + * + * @template T of object */ - private function buildMetadataForComputedFields(ReflectionClass $reflectionClass): void - { + private function buildMetadataForComputedFields( + ReflectionClass $reflectionClass, + string $entityClass, + ClassMetadata|null $classMetadata = null, + ): void { + $owner = $classMetadata === null + ? 'entity ' . $entityClass + : 'repository ' . $reflectionClass->getName() . ' of entity ' . $entityClass; + foreach ($reflectionClass->getMethods() as $reflectionMethod) { // A computed field is computed by calling a public, non-static method if (! $reflectionMethod->isPublic() || $reflectionMethod->isStatic() || $reflectionMethod->isConstructor()) { foreach ($reflectionMethod->getAttributes(Attribute\ComputedField::class) as $attribute) { if ($attribute->newInstance()->getGroup() === $this->config->getGroup()) { throw new MetadataException( - 'Method ' . $reflectionMethod->getName() . ' of entity ' . $reflectionClass->getName() + 'Method ' . $reflectionMethod->getName() . ' of ' . $owner . ' has a ComputedField attribute but is not a public, non-static method.', ); } @@ -336,17 +355,41 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass $fieldName = $instance->getName() ?? $this->deriveFieldNameFromMethod($reflectionMethod->getName()); // Validate no collision with existing fields - if (isset($this->metadata[$reflectionClass->getName()]['fields'][$fieldName])) { + if (isset($this->metadata[$entityClass]['fields'][$fieldName])) { throw new MetadataException( 'Computed field "' . $fieldName . '" collides with existing field in entity ' - . $reflectionClass->getName(), + . $entityClass, + ); + } + + // Nor with another computed field, of the entity or its repository + if (isset($this->metadata[$entityClass]['computedFields'][$fieldName])) { + throw new MetadataException( + 'Computed field "' . $fieldName . '" of method ' . $reflectionMethod->getName() . ' of ' + . $owner . ' collides with another computed field of the same name.', + ); + } + + if ($classMetadata === null && $instance->getBatch()) { + throw new MetadataException( + 'Computed field "' . $fieldName . '" of ' . $owner . ' is batched, but only a method of ' + . 'a repository is given a Collection of entities.', + ); + } + + if ($classMetadata !== null) { + $this->assertRepositoryMethodIsValid( + $reflectionMethod, + $instance->getBatch(), + $classMetadata, + 'Computed field "' . $fieldName . '" of ' . $owner, ); } // Initialize computedFields array if not exists - if (! isset($this->metadata[$reflectionClass->getName()]['computedFields'])) { + if (! isset($this->metadata[$entityClass]['computedFields'])) { /** @psalm-suppress MixedArrayAssignment */ - $this->metadata[$reflectionClass->getName()]['computedFields'] = []; + $this->metadata[$entityClass]['computedFields'] = []; } $computedFieldMetadata = [ @@ -355,10 +398,23 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass 'name' => $fieldName, 'description' => $instance->getDescription(), 'list' => $instance->getList(), - 'args' => $this->buildComputedFieldArguments($reflectionMethod, $instance, $reflectionClass->getName()), + 'args' => $this->buildComputedFieldArguments( + $reflectionMethod, + $instance, + $owner, + $classMetadata !== null, + ), ]; // Left out when unset, so the metadata of a field without them is unchanged + if ($classMetadata !== null) { + $computedFieldMetadata['repository'] = true; + } + + if ($instance->getBatch()) { + $computedFieldMetadata['batch'] = true; + } + if ($instance->getExpression() !== null) { $computedFieldMetadata['expression'] = $instance->getExpression(); } @@ -369,9 +425,101 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass } /** @psalm-suppress MixedArrayAssignment */ - $this->metadata[$reflectionClass->getName()]['computedFields'][$fieldName] = $computedFieldMetadata; + $this->metadata[$entityClass]['computedFields'][$fieldName] = $computedFieldMetadata; + } + } + } + + /** + * Build the metadata for computed fields on the methods of an entity's + * repository. An entity without a repository of its own class has none. + * + * @param ClassMetadata $classMetadata + * @param class-string $entityClass + */ + private function buildMetadataForRepositoryComputedFields(ClassMetadata $classMetadata, string $entityClass): void + { + $repositoryClass = $classMetadata->customRepositoryClassName; + + if ($repositoryClass === null) { + return; + } + + $this->buildMetadataForComputedFields(new ReflectionClass($repositoryClass), $entityClass, $classMetadata); + } + + /** + * A repository's method is given the entity first, or with batch, a + * Collection of entities, keyed by the database value of their identifier. + * A batched entity must have a single identifier which is not an + * association, to key it by. + * + * @param ClassMetadata $classMetadata + * + * @throws MetadataException + */ + private function assertRepositoryMethodIsValid( + ReflectionMethod $method, + bool $batch, + ClassMetadata $classMetadata, + string $prefix, + ): void { + $parameter = $method->getParameters()[0] ?? null; + $given = $batch ? Collection::class : $classMetadata->getName(); + + if ($parameter === null || ! self::parameterAccepts($parameter, $given)) { + throw new MetadataException( + $prefix . ' is of method ' . $method->getName() . ', whose first parameter must accept ' + . ($batch ? 'any ' . Collection::class . ' of entities' : 'the entity') . '.', + ); + } + + if (! $batch) { + return; + } + + $identifiers = $classMetadata->getIdentifierFieldNames(); + + if (count($identifiers) !== 1 || $classMetadata->hasAssociation($identifiers[0])) { + throw new MetadataException( + $prefix . ' is batched, but the entity\'s identifier is composite or an association, which cannot ' + . 'key its entities.', + ); + } + } + + /** + * Whether a parameter accepts any value of a class or interface: it has + * no type, or a type which the class is, or mixed or object, or for a + * Collection, iterable. Any type of a union may accept it. + * + * @param class-string $class + */ + private static function parameterAccepts(ReflectionParameter $parameter, string $class): bool + { + $type = $parameter->getType(); + + if ($type === null) { + return true; + } + + $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + + foreach ($types as $member) { + if (! $member instanceof ReflectionNamedType) { + continue; + } + + $accepts = $member->isBuiltin() + ? in_array($member->getName(), ['mixed', 'object', ...($class === Collection::class ? ['iterable'] : [])], true) + : is_a($class, $member->getName(), true); + + if ($accepts) { + return true; } } + + return false; } /** @@ -379,7 +527,8 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass * 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. + * non-null argument, and its default value is the argument's. The first + * parameter of a repository's method is the entity, not an argument. * * @return array * @@ -388,13 +537,19 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass private function buildComputedFieldArguments( ReflectionMethod $method, Attribute\ComputedField $attribute, - string $entityClass, + string $owner, + bool $isRepository, ): array { $types = $attribute->getArgs(); - $context = ' of computed field method ' . $method->getName() . ' of entity ' . $entityClass; + $context = ' of computed field method ' . $method->getName() . ' of ' . $owner; + + $parameters = $method->getParameters(); + if ($isRepository) { + array_shift($parameters); + } $arguments = []; - foreach ($method->getParameters() as $parameter) { + foreach ($parameters as $parameter) { $name = $parameter->getName(); $prefix = 'Parameter $' . $name . $context; @@ -520,6 +675,52 @@ private function assertReferencedEntitiesAreExposed(array $entityClasses): void } } + /** + * Doctrine gives an entity the repository of a parent entity only when + * the parent is a mapped superclass, so a subclass reads only its own + * repository's computed fields. A row of an exposed subclass is resolved + * by the subclass's fields, even where its parent's type is queried, so + * a subclass without a computed field of its exposed parent's repository + * would silently give null for it. + * + * @param list $entityClasses Every entity class of the entity manager + * + * @throws MetadataException + */ + private function assertSubclassesHaveRepositoryFields(array $entityClasses): void + { + /** @psalm-suppress MixedAssignment The metadata built above, as a listener may have changed it */ + foreach ($this->metadata as $entityClass => $entityMetadata) { + if (! is_array($entityMetadata) || ! in_array($entityClass, $entityClasses, true)) { + continue; + } + + $computed = $entityMetadata['computedFields'] ?? []; + assert(is_array($computed)); + + foreach ($this->entityManager->getClassMetadata($entityClass)->parentClasses as $parent) { + /** @psalm-suppress MixedAssignment The metadata built above */ + $parentMetadata = $this->metadata[$parent] ?? null; + $parentComputed = is_array($parentMetadata) ? $parentMetadata['computedFields'] ?? [] : []; + assert(is_array($parentComputed)); + + /** @psalm-suppress MixedAssignment The metadata built above */ + foreach ($parentComputed as $fieldName => $computedField) { + if (! is_array($computedField) || ($computedField['repository'] ?? false) !== true || isset($computed[$fieldName])) { + continue; + } + + throw new MetadataException( + 'Entity ' . $entityClass . ' extends ' . $parent . ', whose repository gives computed field ' + . $fieldName . ', but the repository of ' . $entityClass . ' does not. Doctrine does not ' + . 'give an entity its parent entity\'s repository: give ' . $entityClass . ' a repository ' + . 'which extends its parent\'s.', + ); + } + } + } + } + /** * The expression of a computed field is checked when the metadata is * built, after the metadata.build event, so a bad one fails at startup diff --git a/src/Resolve/ComputedFieldBatch.php b/src/Resolve/ComputedFieldBatch.php new file mode 100644 index 0000000..e82c081 --- /dev/null +++ b/src/Resolve/ComputedFieldBatch.php @@ -0,0 +1,88 @@ + The entities, by the database value of their identifier */ + private array $entities = []; + + /** @var array The values, by the database value of the entity's identifier */ + private array $values = []; + + private bool $loaded = false; + + /** Thrown while loading, and so for every entity */ + private Throwable|null $error = null; + + /** + * @param class-string $entityClass + * @param Closure(Collection, array): mixed $extractor + * @param array $args + */ + public function __construct( + public readonly string $entityClass, + public readonly string $fieldName, + public readonly Closure $extractor, + public readonly array $args, + ) { + } + + public function add(int|string $key, object $entity): void + { + $this->entities[$key] = $entity; + } + + /** @return array */ + public function getEntities(): array + { + return $this->entities; + } + + /** @param array $values */ + public function setValues(array $values): void + { + $this->values = $values; + } + + public function setError(Throwable $error): void + { + $this->error = $error; + } + + /** + * The value of an entity, or null when the method gave none + * + * @throws Throwable The error thrown while loading the batch. + */ + public function getValue(int|string $key): mixed + { + if ($this->error !== null) { + throw $this->error; + } + + return $this->values[$key] ?? null; + } + + public function markLoaded(): void + { + $this->loaded = true; + } + + public function isLoaded(): bool + { + return $this->loaded; + } +} diff --git a/src/Resolve/ComputedFieldBatchLoader.php b/src/Resolve/ComputedFieldBatchLoader.php new file mode 100644 index 0000000..7b203c1 --- /dev/null +++ b/src/Resolve/ComputedFieldBatchLoader.php @@ -0,0 +1,162 @@ + */ + private array $batches = []; + + /** @param positive-int $chunkSize The most entities given to one call of the method */ + public function __construct( + private readonly EntityManager $entityManager, + private readonly int $chunkSize = 1000, + ) { + } + + /** + * Return a Deferred of the value of a batched computed field for an entity + * + * @param class-string $entityClass The exposed class of the entity + * @param Closure(Collection, array): mixed $extractor + * @param array $args The field's arguments + */ + public function defer( + object $entity, + string $entityClass, + string $fieldName, + Closure $extractor, + array $args, + ): Deferred { + $key = $entityClass . "\0" . $fieldName . "\0" . serialize($args); + $batch = $this->batches[$key] ??= new ComputedFieldBatch($entityClass, $fieldName, $extractor, $args); + + $entityKey = $this->keyOf($entity, $entityClass); + $batch->add($entityKey, $entity); + + return new Deferred(function () use ($key, $batch, $entityKey): mixed { + if (! $batch->isLoaded()) { + // Entities registered from now on start a new batch + if (($this->batches[$key] ?? null) === $batch) { + unset($this->batches[$key]); + } + + // An error loading the batch is the error of every entity's field + try { + $batch->setValues($this->load($batch)); + } catch (Throwable $error) { + $batch->setError($error); + } + + $batch->markLoaded(); + } + + return $batch->getValue($entityKey); + }); + } + + /** + * The value of a batched computed field for one entity, given alone to + * the method + * + * @param class-string $entityClass The exposed class of the entity + * @param Closure(Collection, array): mixed $extractor + * @param array $args The field's arguments + */ + public function loadOne( + object $entity, + string $entityClass, + string $fieldName, + Closure $extractor, + array $args, + ): mixed { + $batch = new ComputedFieldBatch($entityClass, $fieldName, $extractor, $args); + $key = $this->keyOf($entity, $entityClass); + $batch->add($key, $entity); + $batch->setValues($this->load($batch)); + + return $batch->getValue($key); + } + + /** + * Call the method for each chunk of the batch's entities + * + * @return array + * + * @throws GraphQLException + */ + private function load(ComputedFieldBatch $batch): array + { + $values = []; + + foreach (array_chunk($batch->getEntities(), $this->chunkSize, true) as $chunk) { + /** @psalm-suppress MixedAssignment The method may return anything */ + $result = ($batch->extractor)(new ArrayCollection($chunk), $batch->args); + + if ($result instanceof Collection) { + $result = $result->toArray(); + } + + if (! is_array($result)) { + throw new GraphQLException( + 'Computed field ' . $batch->fieldName . ' of entity ' . $batch->entityClass . ' is batched, ' + . 'but its method did not return an array or a Collection of values keyed by identifier.', + ); + } + + $values += $result; + } + + return $values; + } + + /** + * The database value of an entity's identifier, which keys it. An + * identifier whose database value is not an int or a string is keyed by + * its string or serialized form. + * + * @param class-string $entityClass + */ + private function keyOf(object $entity, string $entityClass): int|string + { + $metadata = $this->entityManager->getClassMetadata($entityClass); + $idField = $metadata->getSingleIdentifierFieldName(); + $identifier = $this->entityManager->getUnitOfWork()->getEntityIdentifier($entity); + + /** @psalm-suppress MixedAssignment An identifier may be of any type */ + $id = self::databaseValueOf($this->entityManager, $metadata, $idField, $identifier[$idField]); + + return is_int($id) ? $id : (is_scalar($id) ? (string) $id : serialize($id)); + } +} diff --git a/src/Resolve/FieldResolver.php b/src/Resolve/FieldResolver.php index 2b91280..ace4f64 100644 --- a/src/Resolve/FieldResolver.php +++ b/src/Resolve/FieldResolver.php @@ -8,6 +8,7 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\DoctrineObjectWithComputed; use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\EntityTypeContainer; use Doctrine\Persistence\Proxy; +use GraphQL\Deferred; use GraphQL\Error\Error; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\ResolveInfo; @@ -40,6 +41,7 @@ public function __construct( protected readonly Config $config, protected readonly EntityTypeContainer $entityTypeContainer, protected readonly ToOneLoader $toOneLoader, + protected readonly ComputedFieldBatchLoader $computedFieldBatchLoader, ) { $this->extractValues = self::newExtractCache(); } @@ -92,8 +94,21 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo } if (! array_key_exists($key, $values)) { + // A batched field is loaded with the other entities waiting for it + $batchExtractor = $this->config->getBatchAssociations() + ? $hydrator->getBatchExtractor($info->fieldName) + : null; + /** @psalm-suppress MixedAssignment */ - $values[$key] = $hydrator->extractComputedField($source, $info->fieldName, $args); + $values[$key] = $batchExtractor !== null + ? $this->computedFieldBatchLoader->defer( + $source, + $this->entityTypeContainer->getExposedClass($source), + $info->fieldName, + $batchExtractor, + $args, + ) + : $hydrator->extractComputedField($source, $info->fieldName, $args); $this->extractValues[$source] = $values; } } @@ -107,6 +122,19 @@ public function __invoke(mixed $source, mixed $args, mixed $context, ResolveInfo return $value; } + // The value of a batched computed field, once loaded + if ($value instanceof Deferred) { + return $value->then(fn (mixed $loaded): mixed => $this->deferEntities($loaded, $info)); + } + + return $this->deferEntities($value, $info); + } + + /** + * The entities of a value, loaded in a batch with the others + */ + private function deferEntities(mixed $value, ResolveInfo $info): mixed + { // The entities of a list, such as a computed field may return, are // loaded in a batch with the others if (is_iterable($value) && Type::getNullableType($info->returnType) instanceof ListOfType) { diff --git a/src/Services.php b/src/Services.php index 4fbf7e6..33e010e 100644 --- a/src/Services.php +++ b/src/Services.php @@ -99,6 +99,7 @@ static function (Container $container) use ($metadata) { $self->service(Config::class), $self->service(Type\Entity\EntityTypeContainer::class), $self->service(Resolve\ToOneLoader::class), + $self->service(Resolve\ComputedFieldBatchLoader::class), ); }), ) @@ -110,6 +111,14 @@ static function (Container $container) { return new Resolve\ToOneLoader($entityManager); }, ) + ->set( + Resolve\ComputedFieldBatchLoader::class, + static function (Container $container) { + $entityManager = $container->service(EntityManager::class); + + return new Resolve\ComputedFieldBatchLoader($entityManager); + }, + ) ->set( Resolve\ResolveCollectionFactory::class, (new ReflectionClass(Resolve\ResolveCollectionFactory::class)) @@ -170,6 +179,7 @@ static function (Container $container) { $object->__construct( $self->service(EntityManager::class), $self->service(Type\Entity\EntityTypeContainer::class), + $self->service(Resolve\ComputedFieldBatchLoader::class), ); }), ) diff --git a/test/Entity/RepositoryAnimal.php b/test/Entity/RepositoryAnimal.php new file mode 100644 index 0000000..d62a0f1 --- /dev/null +++ b/test/Entity/RepositoryAnimal.php @@ -0,0 +1,52 @@ + RepositoryAnimal::class, + 'cat' => RepositoryCat::class, + 'dog' => RepositoryDog::class, + 'bird' => RepositoryBird::class, +])] +class RepositoryAnimal +{ + #[GraphQL\Field(group: 'RepositoryInheritance')] + #[GraphQL\Field(group: 'RepositoryInheritanceMissing')] + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private int $id; + + public function __construct( + #[GraphQL\Field(group: 'RepositoryInheritance')] + #[ORM\Column(type: 'string')] + private string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/test/Entity/RepositoryArtist.php b/test/Entity/RepositoryArtist.php new file mode 100644 index 0000000..596beb7 --- /dev/null +++ b/test/Entity/RepositoryArtist.php @@ -0,0 +1,82 @@ + */ + #[ORM\OneToMany(targetEntity: RepositoryRecording::class, mappedBy: 'artist')] + private Collection $recordings; + + public function __construct( + #[GraphQL\Field(group: 'RepositoryComputed')] + #[GraphQL\Field(group: 'RepositoryFieldCollision')] + #[ORM\Column(type: 'string')] + private string $name, + ) { + $this->recordings = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getLabel(): RepositoryLabel|null + { + return $this->label; + } + + public function setLabel(RepositoryLabel $label): self + { + $this->label = $label; + + return $this; + } + + /** @return Collection */ + public function getRecordings(): Collection + { + return $this->recordings; + } + + /** Collides with the repository's computed field of the same name */ + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryCollision')] + public function getDisplayName(): string + { + return $this->name; + } +} diff --git a/test/Entity/RepositoryBird.php b/test/Entity/RepositoryBird.php new file mode 100644 index 0000000..0473837 --- /dev/null +++ b/test/Entity/RepositoryBird.php @@ -0,0 +1,18 @@ +firstId; + } + + public function getSecondId(): int + { + return $this->secondId; + } +} diff --git a/test/Entity/RepositoryDerivedKey.php b/test/Entity/RepositoryDerivedKey.php new file mode 100644 index 0000000..9f1e588 --- /dev/null +++ b/test/Entity/RepositoryDerivedKey.php @@ -0,0 +1,29 @@ +owner; + } +} diff --git a/test/Entity/RepositoryDog.php b/test/Entity/RepositoryDog.php new file mode 100644 index 0000000..e2700e4 --- /dev/null +++ b/test/Entity/RepositoryDog.php @@ -0,0 +1,18 @@ +id; + } + + /** Only a method of a repository is batched */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBatchOnEntity', batch: true)] + public function getCount(): int + { + return 1; + } +} diff --git a/test/Entity/RepositoryLabel.php b/test/Entity/RepositoryLabel.php new file mode 100644 index 0000000..5cfd064 --- /dev/null +++ b/test/Entity/RepositoryLabel.php @@ -0,0 +1,53 @@ + */ + #[GraphQL\Association(group: 'RepositoryComputed')] + #[ORM\OneToMany(targetEntity: RepositoryArtist::class, mappedBy: 'label')] + private Collection $artists; + + public function __construct( + #[GraphQL\Field(group: 'RepositoryComputed')] + #[ORM\Column(type: 'string')] + private string $name, + ) { + $this->artists = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + /** @return Collection */ + public function getArtists(): Collection + { + return $this->artists; + } +} diff --git a/test/Entity/RepositoryRecording.php b/test/Entity/RepositoryRecording.php new file mode 100644 index 0000000..b1f3a28 --- /dev/null +++ b/test/Entity/RepositoryRecording.php @@ -0,0 +1,55 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getReleased(): DateTimeImmutable + { + return $this->released; + } + + public function getArtist(): RepositoryArtist + { + return $this->artist; + } +} diff --git a/test/Entity/TypedIdAuthor.php b/test/Entity/TypedIdAuthor.php index 4043b71..58f1770 100644 --- a/test/Entity/TypedIdAuthor.php +++ b/test/Entity/TypedIdAuthor.php @@ -8,6 +8,7 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy\ToString; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\DbalType\Code; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\DbalType\CodeType; +use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Repository\TypedIdAuthorRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -17,7 +18,8 @@ * as a binary UUID is */ #[GraphQL\Entity(group: 'TypedId')] -#[ORM\Entity] +#[GraphQL\Entity(group: 'TypedIdRepository')] +#[ORM\Entity(repositoryClass: TypedIdAuthorRepository::class)] class TypedIdAuthor { /** @var Collection */ @@ -31,6 +33,7 @@ public function __construct( #[ORM\Column(type: CodeType::NAME)] private Code $id, #[GraphQL\Field(group: 'TypedId')] + #[GraphQL\Field(group: 'TypedIdRepository')] #[ORM\Column(type: 'string')] private string $name, ) { diff --git a/test/Feature/ComputedField/RepositoryComputedFieldInheritanceTest.php b/test/Feature/ComputedField/RepositoryComputedFieldInheritanceTest.php new file mode 100644 index 0000000..13120a3 --- /dev/null +++ b/test/Feature/ComputedField/RepositoryComputedFieldInheritanceTest.php @@ -0,0 +1,102 @@ +getEntityManager(); + $entityManager->persist(new RepositoryAnimal('Generic')); + $entityManager->persist(new RepositoryCat('Tom')); + $entityManager->persist(new RepositoryDog('Rex')); + $entityManager->persist(new RepositoryBird('Tweety')); + $entityManager->flush(); + $entityManager->clear(); + + $this->driver = new Driver($entityManager, new Config(['group' => 'RepositoryInheritance'])); + } + + public function testEachClassHasTheFieldsOfItsOwnRepository(): void + { + $metadata = $this->driver->get(Metadata::class)->toArray(); + + $this->assertSame(['sound'], array_keys($metadata[RepositoryAnimal::class]['computedFields'])); + $this->assertSame(['lives', 'sound'], array_keys($metadata[RepositoryCat::class]['computedFields'])); + $this->assertSame(['sound'], array_keys($metadata[RepositoryDog::class]['computedFields'])); + } + + public function testEachRowIsGivenToTheRepositoryOfItsExposedClass(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => [ + 'animals' => $this->driver->completeConnection(RepositoryAnimal::class), + 'cats' => $this->driver->completeConnection(RepositoryCat::class), + ], + ]), + ]); + + $result = GraphQL::executeQuery( + $schema, + '{ animals { edges { node { name sound } } } cats { edges { node { name sound lives } } } }', + )->toArray(); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame( + [ + 'RepositoryAnimalRepository: Generic', + 'RepositoryCatRepository: Tom', + 'RepositoryDogRepository: Rex', + 'RepositoryAnimalRepository: Tweety', + ], + array_column(array_column($result['data']['animals']['edges'], 'node'), 'sound'), + ); + $this->assertSame( + [['name' => 'Tom', 'sound' => 'RepositoryCatRepository: Tom', 'lives' => 9]], + array_column($result['data']['cats']['edges'], 'node'), + ); + } + + public function testAnExposedSubclassWithoutItsParentsRepositoryFieldsIsAnError(): void + { + $this->expectException(MetadataException::class); + $this->expectExceptionMessage( + 'Entity ' . RepositoryBird::class . ' extends ' . RepositoryAnimal::class . ', whose repository gives ' + . 'computed field sound, but the repository of ' . RepositoryBird::class . ' does not. Doctrine does ' + . 'not give an entity its parent entity\'s repository: give ' . RepositoryBird::class . ' a repository ' + . 'which extends its parent\'s.', + ); + + (new Driver($this->getEntityManager(), new Config(['group' => 'RepositoryInheritanceMissing']))) + ->get(Metadata::class); + } +} diff --git a/test/Feature/ComputedField/RepositoryComputedFieldTest.php b/test/Feature/ComputedField/RepositoryComputedFieldTest.php new file mode 100644 index 0000000..493f47f --- /dev/null +++ b/test/Feature/ComputedField/RepositoryComputedFieldTest.php @@ -0,0 +1,465 @@ +populateRepositoryData($this->getEntityManager()); + RepositoryArtistRepository::$batchCalls = 0; + $this->resetQueries(); + } + + /** + * Two labels and five artists, by identifier: Phish (Elektra, 4 + * recordings), Ween (Elektra, 2), moe. (Elektra, 0), Gov't Mule (Relix, 3) + * and Grateful Dead (Relix, 1) + */ + private function populateRepositoryData(EntityManager $entityManager): void + { + $labels = []; + foreach (['Elektra', 'Relix'] as $name) { + $labels[$name] = new RepositoryLabel($name); + $entityManager->persist($labels[$name]); + } + + $artists = [ + ['Phish', 'Elektra', ['2002-05-14', '2002-11-05', '2003-10-14', '2004-06-15']], + ['Ween', 'Elektra', ['2003-07-15', '2007-10-23']], + ['moe.', 'Elektra', []], + ["Gov't Mule", 'Relix', ['2001-06-12', '2003-09-23', '2004-08-17']], + ['Grateful Dead', 'Relix', ['1977-05-08']], + ]; + + foreach ($artists as [$name, $label, $releases]) { + $artist = (new RepositoryArtist($name))->setLabel($labels[$label]); + $entityManager->persist($artist); + + foreach ($releases as $i => $released) { + $entityManager->persist(new RepositoryRecording( + $name . ' ' . $i, + new DateTimeImmutable($released), + $artist, + )); + } + } + + $entityManager->flush(); + $entityManager->clear(); + } + + /** @param array $config */ + private function getDriver(array $config = [], EntityManager|null $entityManager = null): Driver + { + return new Driver( + $entityManager ?? $this->getEntityManager(), + new Config(['group' => 'RepositoryComputed'] + $config), + ); + } + + /** @return array */ + private function execute(Driver $driver, string $query): array + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => [ + 'artists' => $driver->completeConnection(RepositoryArtist::class), + 'labels' => $driver->completeConnection(RepositoryLabel::class), + ], + ]), + ]); + + $this->getEntityManager()->clear(); + $this->resetQueries(); + + return GraphQL::executeQuery($schema, $query)->toArray(DebugFlag::INCLUDE_DEBUG_MESSAGE); + } + + /** + * The nodes of the artists connection + * + * @return list> + */ + private function artists(Driver $driver, string $fields, string $args = ''): array + { + $result = $this->execute($driver, '{ artists' . $args . ' { edges { node { ' . $fields . ' } } } }'); + + $this->assertArrayNotHasKey('errors', $result); + assert(is_array($result['data']) && is_array($result['data']['artists'])); + + /** @var list}> $edges */ + $edges = $result['data']['artists']['edges']; + + return array_column($edges, 'node'); + } + + public function testUnbatchedFields(): void + { + $artists = $this->artists( + $this->getDriver(), + 'name displayName recordingTotal in2003: recordingTotal(year: 2003)', + ); + + $this->assertSame( + [ + ['name' => 'Phish', 'displayName' => 'Phish', 'recordingTotal' => 4, 'in2003' => 1], + ['name' => 'Ween', 'displayName' => 'Ween', 'recordingTotal' => 2, 'in2003' => 1], + ['name' => 'moe.', 'displayName' => 'moe.', 'recordingTotal' => 0, 'in2003' => 0], + ['name' => "Gov't Mule", 'displayName' => "Gov't Mule", 'recordingTotal' => 3, 'in2003' => 1], + ['name' => 'Grateful Dead', 'displayName' => 'Grateful Dead', 'recordingTotal' => 1, 'in2003' => 0], + ], + $artists, + ); + } + + public function testBatchedFieldIsOneQuery(): void + { + $artists = $this->artists($this->getDriver(), 'name recordingCount'); + + // An artist the method gives no value for is null + $this->assertSame([4, 2, null, 3, 1], array_column($artists, 'recordingCount')); + $this->assertSame(1, RepositoryArtistRepository::$batchCalls); + + // The artists and their counts + $this->assertSame(2, $this->queryCount()); + } + + public function testBatchedFieldInABatchedCollection(): void + { + $result = $this->execute( + $this->getDriver(), + '{ labels { edges { node { name artists { edges { node { name recordingCount } } } } } } }', + ); + + $this->assertArrayNotHasKey('errors', $result); + assert(is_array($result['data']) && is_array($result['data']['labels'])); + + $counts = []; + /** @var list}}}> $labels */ + $labels = $result['data']['labels']['edges']; + foreach ($labels as $label) { + foreach ($label['node']['artists']['edges'] as $artist) { + $counts[$label['node']['name']][$artist['node']['name']] = $artist['node']['recordingCount']; + } + } + + $this->assertSame( + [ + 'Elektra' => ['Phish' => 4, 'Ween' => 2, 'moe.' => null], + 'Relix' => ["Gov't Mule" => 3, 'Grateful Dead' => 1], + ], + $counts, + ); + + // The artists of both labels in one call + $this->assertSame(1, RepositoryArtistRepository::$batchCalls); + } + + public function testArgumentsUnderAliasesAreBatchedSeparately(): void + { + $artists = $this->artists( + $this->getDriver(), + 'all: recordingCount in2003: recordingCount(year: 2003) again: recordingCount', + ); + + $this->assertSame([4, 2, null, 3, 1], array_column($artists, 'all')); + $this->assertSame([1, 1, null, 1, null], array_column($artists, 'in2003')); + $this->assertSame([4, 2, null, 3, 1], array_column($artists, 'again')); + $this->assertSame(2, RepositoryArtistRepository::$batchCalls); + } + + public function testFilteredAndSortedByItsExpression(): void + { + $artists = $this->artists( + $this->getDriver(), + 'name recordingCount', + '(filter: { recordingCount: { gt: 1, sort: DESC } })', + ); + + $this->assertSame( + [ + ['name' => 'Phish', 'recordingCount' => 4], + ['name' => "Gov't Mule", 'recordingCount' => 3], + ['name' => 'Ween', 'recordingCount' => 2], + ], + $artists, + ); + + $artists = $this->artists( + $this->getDriver(), + 'name', + '(filter: { recordingCount: { args: { year: 2003 }, eq: 1 } })', + ); + + $this->assertSame(['Phish', 'Ween', "Gov't Mule"], array_column($artists, 'name')); + } + + public function testEntityTypedFields(): void + { + $artists = $this->artists( + $this->getDriver(), + 'name latestRecording { title } recordingList { title }', + ); + + $this->assertSame( + [ + 'Phish 3', + 'Ween 1', + null, + "Gov't Mule 2", + 'Grateful Dead 0', + ], + array_map( + static fn (array $artist): mixed => is_array($artist['latestRecording']) + ? $artist['latestRecording']['title'] + : $artist['latestRecording'], + $artists, + ), + ); + + $this->assertSame( + [['title' => 'Ween 0'], ['title' => 'Ween 1']], + $artists[1]['recordingList'], + ); + $this->assertNull($artists[2]['recordingList']); + + // The artists, and the recordings of each field, which loads them + $this->assertSame(3, $this->queryCount()); + } + + public function testNotBatchedWhenBatchingIsOff(): void + { + $artists = $this->artists( + $this->getDriver(['batchAssociations' => false]), + 'recordingCount in2003: recordingCount(year: 2003) latestRecording { title }', + ); + + $this->assertSame([4, 2, null, 3, 1], array_column($artists, 'recordingCount')); + $this->assertSame([1, 1, null, 1, null], array_column($artists, 'in2003')); + $this->assertSame('Phish 3', $artists[0]['latestRecording']['title'] ?? null); + + // The method is given each artist alone + $this->assertSame(10, RepositoryArtistRepository::$batchCalls); + } + + public function testExtractLeavesOutBatchedFields(): void + { + $driver = $this->getDriver(); + $hydrator = $driver->get(HydratorContainer::class)->get(RepositoryArtist::class); + assert($hydrator instanceof DoctrineObjectWithComputed); + + $artist = $this->getEntityManager()->getRepository(RepositoryArtist::class)->findOneBy(['name' => 'Phish']); + assert($artist instanceof RepositoryArtist); + + $values = $hydrator->extract($artist); + + // A field with arguments and a batched field are left out + $this->assertSame('Phish', $values['displayName']); + $this->assertArrayNotHasKey('recordingTotal', $values); + $this->assertArrayNotHasKey('recordingCount', $values); + $this->assertArrayNotHasKey('latestRecording', $values); + + // A batched field is computed alone when asked for + $this->assertSame(1, $hydrator->extractComputedField($artist, 'recordingCount', ['year' => 2004])); + $this->assertNotNull($hydrator->getBatchExtractor('recordingCount')); + $this->assertNull($hydrator->getBatchExtractor('displayName')); + } + + public function testAMissingValueOfANonNullTypeIsAnError(): void + { + $driver = new Driver($this->getEntityManager(), new Config(['group' => 'RepositoryNonNull'])); + $driver->get(TypeContainer::class)->set('RepositoryNonNullInt', static fn () => Type::nonNull(Type::int())); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => ['artists' => $driver->completeConnection(RepositoryArtist::class)], + ]), + ]); + + $result = GraphQL::executeQuery($schema, '{ artists { edges { node { id requiredCount } } } }') + ->toArray(DebugFlag::INCLUDE_DEBUG_MESSAGE); + + $this->assertCount(1, $result['errors'] ?? []); + $this->assertSame('Internal server error', $result['errors'][0]['message']); + $this->assertStringContainsString('Cannot return null for non-nullable field', $result['errors'][0]['extensions']['debugMessage']); + $this->assertSame(['artists', 'edges', 2, 'node', 'requiredCount'], $result['errors'][0]['path']); + } + + public function testAMethodWhichReturnsNeitherAnArrayNorACollectionIsAnErrorOfEveryEntity(): void + { + $driver = new Driver($this->getEntityManager(), new Config(['group' => 'RepositoryInvalidReturn'])); + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => ['artists' => $driver->completeConnection(RepositoryArtist::class)], + ]), + ]); + + $result = GraphQL::executeQuery($schema, '{ artists { edges { node { id broken } } } }') + ->toArray(DebugFlag::INCLUDE_DEBUG_MESSAGE); + + $this->assertCount(5, $result['errors'] ?? []); + foreach ($result['errors'] as $error) { + $this->assertSame('Internal server error', $error['message']); + $this->assertSame( + 'Computed field broken of entity ' . RepositoryArtist::class . ' is batched, but its method did not ' + . 'return an array or a Collection of values keyed by identifier.', + $error['extensions']['debugMessage'], + ); + } + } + + public function testTheEntityManagersRepositoryFactoryGivesTheRepository(): void + { + $entityManager = $this->getEntityManager(); + $config = clone $entityManager->getConfiguration(); + $config->setRepositoryFactory(new class implements RepositoryFactory { + /** @var array> */ + private array $repositories = []; + + /** + * @param class-string $entityName + * + * @return EntityRepository + * + * @template T of object + */ + public function getRepository(EntityManagerInterface $entityManager, $entityName): EntityRepository // phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + { + /** @var EntityRepository $repository */ + $repository = $this->repositories[$entityName] ??= $entityName === RepositoryArtist::class + ? new RepositoryArtistRepository( + $entityManager, + $entityManager->getClassMetadata(RepositoryArtist::class), + 'The ', + ) + : new EntityRepository($entityManager, $entityManager->getClassMetadata($entityName)); + + return $repository; + } + }); + + $driver = $this->getDriver([], new EntityManager($entityManager->getConnection(), $config)); + + $this->assertSame( + ['The Phish', 'The Ween', 'The moe.', "The Gov't Mule", 'The Grateful Dead'], + array_column($this->artists($driver, 'displayName'), 'displayName'), + ); + } + + public function testMethodIsCalledForEachChunk(): void + { + $entityManager = $this->getEntityManager(); + $artists = $entityManager->getRepository(RepositoryArtist::class)->findBy([], ['id' => 'ASC']); + $loader = new ComputedFieldBatchLoader($entityManager, 2); + + $chunks = []; + $extractor = static function (Collection $artists, array $args) use (&$chunks): array { + $chunks[] = $artists->getKeys(); + + return $artists->map(static fn (RepositoryArtist $artist): int => strlen($artist->getName()))->toArray(); + }; + + $deferred = array_map( + static fn (RepositoryArtist $artist): Deferred => $loader->defer( + $artist, + RepositoryArtist::class, + 'nameLength', + $extractor, + [], + ), + $artists, + ); + SyncPromise::runQueue(); + + $this->assertSame([[1, 2], [3, 4], [5]], $chunks); + $this->assertSame([5, 4, 4, 10, 13], array_map(static fn (Deferred $value): mixed => $value->result, $deferred)); + } + + public function testMetadata(): void + { + $driver = $this->getDriver(); + $metadata = $driver->get(Metadata::class)->toArray(); + + $computedFields = $metadata[RepositoryArtist::class]['computedFields']; + $this->assertTrue($computedFields['displayName']['repository']); + $this->assertArrayNotHasKey('batch', $computedFields['displayName']); + $this->assertTrue($computedFields['recordingCount']['repository']); + $this->assertTrue($computedFields['recordingCount']['batch']); + + // The first parameter of the method is not an argument + $this->assertSame([], $computedFields['displayName']['args']); + $this->assertSame(['year'], array_keys($computedFields['recordingCount']['args'])); + + // Cached metadata gives the same fields + $cached = new Driver($this->getEntityManager(), new Config(['group' => 'RepositoryComputed']), $metadata); + $artists = $this->artists($cached, 'displayName recordingCount'); + $this->assertSame(['Phish', 4], [$artists[0]['displayName'], $artists[0]['recordingCount']]); + } + + public function testBatchedMetadataMustBeOfARepository(): void + { + $this->expectException(MetadataException::class); + $this->expectExceptionMessage( + 'Metadata for field count is batched but not of a repository. Only a method of a repository is batched.', + ); + + ComputedFieldMetadata::fromArray( + [ + 'method' => 'getCount', + 'type' => 'int', + 'name' => 'count', + 'description' => null, + 'list' => false, + 'args' => [], + 'batch' => true, + ], + 'field count', + ); + } +} diff --git a/test/Feature/ComputedField/RepositoryComputedFieldValidationTest.php b/test/Feature/ComputedField/RepositoryComputedFieldValidationTest.php new file mode 100644 index 0000000..c4f8282 --- /dev/null +++ b/test/Feature/ComputedField/RepositoryComputedFieldValidationTest.php @@ -0,0 +1,127 @@ + */ + public static function invalidProvider(): array + { + $entity = ', whose first parameter must accept the entity.'; + $batch = ', whose first parameter must accept any ' . Collection::class . ' of entities.'; + + return [ + 'another entity' => [ + 'RepositoryWrongEntity', + 'Computed field "wrongEntity"' . self::REPOSITORY . ' is of method wrongEntity' . $entity, + ], + 'no parameter' => [ + 'RepositoryNoParameter', + 'Computed field "noParameter"' . self::REPOSITORY . ' is of method noParameter' . $entity, + ], + 'a batch given an ArrayCollection' => [ + 'RepositoryBatchArrayCollection', + 'Computed field "batchArrayCollection"' . self::REPOSITORY . ' is of method batchArrayCollection' + . $batch, + ], + 'a batch given an array' => [ + 'RepositoryBatchArray', + 'Computed field "batchArray"' . self::REPOSITORY . ' is of method batchArray' . $batch, + ], + 'an intersection or another entity' => [ + 'RepositoryIntersection', + 'Computed field "intersection"' . self::REPOSITORY . ' is of method intersection' . $entity, + ], + 'a scalar' => [ + 'RepositoryBuiltin', + 'Computed field "builtin"' . self::REPOSITORY . ' is of method builtin' . $entity, + ], + 'a method which is not public' => [ + 'RepositoryPrivate', + 'Method hidden' . self::REPOSITORY . ' has a ComputedField attribute but is not a public, ' + . 'non-static method.', + ], + 'an argument without a type' => [ + 'RepositoryArgs', + 'Parameter $since of computed field method since' . self::REPOSITORY . ' is not an int, float, ' + . 'string or bool. Give its type in the args of the ComputedField attribute.', + ], + 'batch on a method of an entity' => [ + 'RepositoryBatchOnEntity', + 'Computed field "count" of entity ' . RepositoryInvalid::class . ' is batched, but only a method ' + . 'of a repository is given a Collection of entities.', + ], + 'batch with a composite identifier' => [ + 'RepositoryBatchKey', + 'Computed field "count" of repository ' . RepositoryKeyRepository::class . ' of entity ' + . RepositoryCompositeKey::class . ' is batched, but the entity\'s identifier is composite or an ' + . 'association, which cannot key its entities.', + ], + 'batch with an association as the identifier' => [ + 'RepositoryBatchDerivedKey', + 'Computed field "count" of repository ' . RepositoryKeyRepository::class . ' of entity ' + . RepositoryDerivedKey::class . ' is batched, but the entity\'s identifier is composite or an ' + . 'association, which cannot key its entities.', + ], + 'the name of a computed field of the entity' => [ + 'RepositoryCollision', + 'Computed field "displayName" of method getDisplayName of repository ' + . RepositoryArtistRepository::class . ' of entity ' . RepositoryArtist::class + . ' collides with another computed field of the same name.', + ], + 'the name of a field' => [ + 'RepositoryFieldCollision', + 'Computed field "name" collides with existing field in entity ' . RepositoryArtist::class, + ], + ]; + } + + #[DataProvider('invalidProvider')] + public function testInvalid(string $group, string $message): void + { + $this->expectException(MetadataException::class); + $this->expectExceptionMessage($message); + + (new Driver($this->getEntityManager(), new Config(['group' => $group])))->get(Metadata::class); + } + + public function testAccepted(): void + { + $metadata = (new Driver($this->getEntityManager(), new Config(['group' => 'RepositoryAccepted']))) + ->get(Metadata::class) + ->toArray(); + + $computedFields = $metadata[RepositoryInvalid::class]['computedFields']; + + $this->assertSame( + ['untyped', 'mixedEntity', 'union', 'iterableBatch', 'traversableBatch'], + array_keys($computedFields), + ); + $this->assertTrue($computedFields['traversableBatch']['batch']); + } +} diff --git a/test/Feature/Resolve/TypedIdentifierTest.php b/test/Feature/Resolve/TypedIdentifierTest.php index 1b0f33d..86b21f5 100644 --- a/test/Feature/Resolve/TypedIdentifierTest.php +++ b/test/Feature/Resolve/TypedIdentifierTest.php @@ -11,6 +11,7 @@ use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TypedIdBook; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Entity\TypedIdTag; use ApiSkeletonsTest\Doctrine\ORM\GraphQL\QueryCountingTestCase; +use ApiSkeletonsTest\Doctrine\ORM\GraphQL\Repository\TypedIdAuthorRepository; use GraphQL\GraphQL; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Schema; @@ -240,4 +241,29 @@ public function testValueTheTypeCannotConvertIsAClientError(): void $result['errors'][0]['message'], ); } + + public function testBatchedComputedFieldIsKeyedByTheDatabaseValue(): void + { + $this->getEntityManager()->clear(); + + $driver = new Driver($this->getEntityManager(), new Config(['group' => 'TypedIdRepository'])); + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'query', + 'fields' => ['authors' => $driver->completeConnection(TypedIdAuthor::class)], + ]), + ]); + + $result = GraphQL::executeQuery($schema, '{ authors { edges { node { name bookCount } } } }')->toArray(); + + $this->assertSame(['code:a', 'code:b', 'code:c'], TypedIdAuthorRepository::$keys); + $this->assertSame( + [ + ['name' => 'Ann', 'bookCount' => 2], + ['name' => 'Bob', 'bookCount' => 1], + ['name' => 'Cy', 'bookCount' => null], + ], + array_column($result['data']['authors']['edges'], 'node'), + ); + } } diff --git a/test/Repository/RepositoryAnimalRepository.php b/test/Repository/RepositoryAnimalRepository.php new file mode 100644 index 0000000..afcba3e --- /dev/null +++ b/test/Repository/RepositoryAnimalRepository.php @@ -0,0 +1,40 @@ + + */ +class RepositoryAnimalRepository extends EntityRepository +{ + /** + * The name of the repository which gave the value, and the animal's + * + * @param Collection $animals + * + * @return array + */ + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryInheritance', batch: true)] + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryInheritanceMissing', batch: true)] + public function getSound(Collection $animals): array + { + $repository = substr((string) strrchr(static::class, '\\'), 1); + + return $animals->map( + static fn (RepositoryAnimal $animal): string => $repository . ': ' . $animal->getName(), + )->toArray(); + } +} diff --git a/test/Repository/RepositoryArtistRepository.php b/test/Repository/RepositoryArtistRepository.php new file mode 100644 index 0000000..1d4b105 --- /dev/null +++ b/test/Repository/RepositoryArtistRepository.php @@ -0,0 +1,192 @@ + + */ +class RepositoryArtistRepository extends EntityRepository +{ + /** The number of times each batched method was called */ + public static int $batchCalls = 0; + + /** @param ClassMetadata $class */ + public function __construct( + EntityManagerInterface $em, + ClassMetadata $class, + private readonly string $prefix = '', + ) { + parent::__construct($em, $class); + } + + /** Uses the repository's own state, which a repository factory may give it */ + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryComputed')] + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryCollision')] + #[GraphQL\ComputedField(type: 'string', group: 'RepositoryFieldCollision', name: 'name')] + public function getDisplayName(RepositoryArtist $artist): string + { + return $this->prefix . $artist->getName(); + } + + /** Not batched: a query for each artist */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryComputed')] + public function getRecordingTotal(RepositoryArtist $artist, int|null $year = null): int + { + return (int) $this->recordings($year) + ->select('COUNT(r.id)') + ->andWhere('r.artist = :artist') + ->setParameter('artist', $artist) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * Batched, and filtered and sorted by its expression. An artist without + * recordings is left out, so its value is null. + * + * @param Collection $artists + * + * @return array + */ + #[GraphQL\ComputedField( + type: 'int', + group: 'RepositoryComputed', + expression: '(SELECT COUNT({r}.id) FROM ' . RepositoryRecording::class . ' {r} WHERE {r}.artist = {entity}' + . ' AND ({:year} IS NULL OR {r}.released BETWEEN ' + . "CONCAT({:year}, '-01-01') AND CONCAT({:year}, '-12-31')))", + batch: true, + )] + public function getRecordingCount(Collection $artists, int|null $year = null): array + { + self::$batchCalls++; + + return array_column( + $this->recordings($year) + ->select('IDENTITY(r.artist) AS id', 'COUNT(r.id) AS total') + ->andWhere('r.artist IN (:artists)') + ->groupBy('r.artist') + ->setParameter('artists', $artists->getKeys()) + ->getQuery() + ->getScalarResult(), + 'total', + 'id', + ); + } + + /** + * An entity, returned in a Collection + * + * @param Collection $artists + * + * @return Collection + */ + #[GraphQL\ComputedField(type: RepositoryRecording::class, group: 'RepositoryComputed', batch: true)] + public function getLatestRecording(Collection $artists): Collection + { + $latest = new ArrayCollection(); + + foreach ($this->recordingsOf($artists) as $row) { + $latest->set($row['artistId'], $row[0]); + } + + return $latest; + } + + /** + * A list of entities + * + * @param Collection $artists + * + * @return array> + */ + #[GraphQL\ComputedField(type: RepositoryRecording::class, group: 'RepositoryComputed', list: true, batch: true)] + public function getRecordingList(Collection $artists): array + { + $recordings = []; + + foreach ($this->recordingsOf($artists) as $row) { + $recordings[$row['artistId']][] = $row[0]; + } + + return $recordings; + } + + /** + * A non-null type, which an artist without recordings has no value of + * + * @param Collection $artists + * + * @return array + */ + #[GraphQL\ComputedField(type: 'RepositoryNonNullInt', group: 'RepositoryNonNull', batch: true)] + public function getRequiredCount(Collection $artists): array + { + return $this->getRecordingCount($artists); + } + + /** + * Not an array or a Collection + * + * @param Collection $artists + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryInvalidReturn', batch: true)] + public function getBroken(Collection $artists): int + { + return count($artists); + } + + /** + * The recordings of artists, oldest first, with the identifier of each + * recording's artist + * + * @param Collection $artists + * + * @return list + */ + private function recordingsOf(Collection $artists): array + { + /** @var list $rows */ + $rows = $this->recordings(null) + ->select('r', 'IDENTITY(r.artist) AS artistId') + ->andWhere('r.artist IN (:artists)') + ->orderBy('r.released') + ->setParameter('artists', $artists->getKeys()) + ->getQuery() + ->getResult(); + + return $rows; + } + + private function recordings(int|null $year): QueryBuilder + { + $queryBuilder = $this->getEntityManager()->createQueryBuilder() + ->from(RepositoryRecording::class, 'r'); + + if ($year !== null) { + $queryBuilder + ->andWhere('r.released BETWEEN :from AND :to') + ->setParameter('from', new DateTimeImmutable($year . '-01-01'), 'date_immutable') + ->setParameter('to', new DateTimeImmutable($year . '-12-31'), 'date_immutable'); + } + + return $queryBuilder; + } +} diff --git a/test/Repository/RepositoryCatRepository.php b/test/Repository/RepositoryCatRepository.php new file mode 100644 index 0000000..d4d43e8 --- /dev/null +++ b/test/Repository/RepositoryCatRepository.php @@ -0,0 +1,20 @@ + + */ +class RepositoryInvalidRepository extends EntityRepository +{ + /** Of another entity */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryWrongEntity')] + public function wrongEntity(RepositoryArtist $artist): int + { + return 1; + } + + /** Given nothing */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryNoParameter')] + public function noParameter(): int + { + return 1; + } + + /** + * Only a Collection is promised + * + * @param ArrayCollection $entities + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBatchArrayCollection', batch: true)] + public function batchArrayCollection(ArrayCollection $entities): array + { + return []; + } + + /** + * Not a Collection + * + * @param array $entities + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBatchArray', batch: true)] + public function batchArray(array $entities): array + { + return $entities; + } + + /** + * An intersection, and another entity + * + * @param (Countable&ArrayAccess)|RepositoryArtist $entity + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryIntersection')] + public function intersection((Countable&ArrayAccess)|RepositoryArtist $entity): int + { + return 1; + } + + /** A scalar */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBuiltin')] + public function builtin(int $entity): int + { + return $entity; + } + + /** + * Accepted: no type + * + * @param RepositoryInvalid $entity + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryAccepted')] + public function untyped($entity): int // phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + { + return 1; + } + + /** Accepted: mixed */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryAccepted')] + public function mixedEntity(mixed $entity): int + { + return 1; + } + + /** Accepted: a union, one of whose types is the entity */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryAccepted')] + public function union(RepositoryArtist|RepositoryInvalid $entity): int + { + return 1; + } + + /** + * Accepted: iterable, for a Collection + * + * @param iterable $entities + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryAccepted', batch: true)] + public function iterableBatch(iterable $entities): array + { + return []; + } + + /** + * Accepted: an interface which a Collection extends + * + * @param Traversable $entities + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryAccepted', batch: true)] + public function traversableBatch(Traversable $entities): array + { + return []; + } + + /** Not public */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryPrivate')] + protected function hidden(RepositoryInvalid $entity): int + { + return 1; + } + + /** An argument whose type is not given */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryArgs')] + public function since(RepositoryInvalid $entity, DateTimeImmutable $since): int + { + return 1; + } +} diff --git a/test/Repository/RepositoryKeyRepository.php b/test/Repository/RepositoryKeyRepository.php new file mode 100644 index 0000000..bf05704 --- /dev/null +++ b/test/Repository/RepositoryKeyRepository.php @@ -0,0 +1,29 @@ + + */ +class RepositoryKeyRepository extends EntityRepository +{ + /** + * @param Collection $entities + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBatchKey', batch: true)] + #[GraphQL\ComputedField(type: 'int', group: 'RepositoryBatchDerivedKey', batch: true)] + public function getCount(Collection $entities): array + { + return []; + } +} diff --git a/test/Repository/TypedIdAuthorRepository.php b/test/Repository/TypedIdAuthorRepository.php new file mode 100644 index 0000000..575a0a2 --- /dev/null +++ b/test/Repository/TypedIdAuthorRepository.php @@ -0,0 +1,53 @@ + + */ +class TypedIdAuthorRepository extends EntityRepository +{ + /** @var list The keys of the entities the method was last given */ + public static array $keys = []; + + /** + * Keyed by IDENTITY(), the database value of the identifier, as the + * entities are. The keys are bound rather than the entities, which + * Doctrine does not convert in an IN list. + * + * @param Collection $authors + * + * @return array + */ + #[GraphQL\ComputedField(type: 'int', group: 'TypedIdRepository', batch: true)] + public function getBookCount(Collection $authors): array + { + self::$keys = $authors->getKeys(); + + return array_column( + $this->getEntityManager()->createQueryBuilder() + ->select('IDENTITY(b.author) AS id', 'COUNT(b.id) AS total') + ->from(TypedIdBook::class, 'b') + ->where('b.author IN (:authors)') + ->groupBy('b.author') + ->setParameter('authors', $authors->getKeys()) + ->getQuery() + ->getScalarResult(), + 'total', + 'id', + ); + } +}