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', + ); + } +}