From 320841580d83e07cc39c9d23b91681740b012d92 Mon Sep 17 00:00:00 2001 From: Tom H Anderson Date: Sun, 4 Oct 2026 16:57:09 -0600 Subject: [PATCH] Filter and sort computed fields by a DQL expression A ComputedField may give an expression, the DQL of its value. The field then has filters, sort and sortPriority among them, applied to the expression as a field's are applied to its column. - Every alias in an expression is a {placeholder}, renamed on each use, so two filters, or a filter and a sort, on one field do not collide. {entity} is the root entity. {:name} is the argument name, bound from the filter's args, else its default, else null. - A sort selects the expression AS HIDDEN and orders by it. The batch id-pair query keeps the hidden selects when it replaces the select; the batch count query applies no sorts (Filter\QueryBuilder::apply() takes $sort). - Each expression is parsed with Query::getAST(), without a database, after the metadata.build event. Invalid DQL, an alias without a placeholder, an unknown {:name}, an expression on a list or an entity type, and excluded filters without an expression throw Exception\Metadata. - DQL takes a subquery only for comparisons, so between is applied as two comparisons, and the in, notin, isnull and LIKE filters DQL rejects for an expression are added to its excludeFilters in the metadata. - excludeFilters and includeFilters on ComputedField limit its filters. The expression and excludeFilters keys are left out of the metadata of a field without them, so Metadata::FORMAT_VERSION is unchanged. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/attributes.rst | 11 +- docs/computed-fields.rst | 150 +++++- docs/events.rst | 5 + docs/versions.rst | 2 + src/Attribute/ComputedField.php | 40 +- src/Filter/ComputedFieldExpression.php | 76 +++ src/Filter/FilterFactory.php | 126 +++++ src/Filter/InputObjectType/Field.php | 25 +- src/Filter/QueryBuilder.php | 179 ++++++- src/Metadata/ComputedFieldMetadata.php | 31 +- src/Metadata/MetadataFactory.php | 180 +++++++ src/Resolve/ResolveCollectionFactory.php | 58 +- src/Type/Entity/Entity.php | 23 +- test/Entity/ComputedExpressionArtist.php | 160 ++++++ test/Entity/ComputedExpressionLabel.php | 54 ++ test/Entity/ComputedExpressionRecording.php | 60 +++ .../ComputedFieldExpressionTest.php | 495 ++++++++++++++++++ .../ComputedFieldExpressionValidationTest.php | 136 +++++ 19 files changed, 1762 insertions(+), 51 deletions(-) create mode 100644 src/Filter/ComputedFieldExpression.php create mode 100644 test/Entity/ComputedExpressionArtist.php create mode 100644 test/Entity/ComputedExpressionLabel.php create mode 100644 test/Entity/ComputedExpressionRecording.php create mode 100644 test/Feature/ComputedField/ComputedFieldExpressionTest.php create mode 100644 test/Feature/ComputedField/ComputedFieldExpressionValidationTest.php diff --git a/CLAUDE.md b/CLAUDE.md index dcb88eda..7451f54c 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 +- `#[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 - `#[ExcludeFilters]` - Excludes specific filters ### Event System (PSR-14) diff --git a/docs/attributes.rst b/docs/attributes.rst index 4f6909a2..55fec1e5 100644 --- a/docs/attributes.rst +++ b/docs/attributes.rst @@ -276,6 +276,12 @@ is built. * ``name`` - An override for the field name in GraphQL. If not provided, the name is derived from the method name (``getFullName`` becomes ``fullName``). * ``group`` - You can have multiple GraphQL configurations organized by ``group``. +* ``expression`` - The DQL expression of the field's value, by which it is + filtered and sorted. Every alias in it is a placeholder, such as + ``{entity}``; see `computed fields `_. +* ``excludeFilters`` - Filters to exclude for a field with an ``expression``. +* ``includeFilters`` - The only filters to allow for a field with an + ``expression``. Mutually exclusive with ``excludeFilters``. Example: @@ -300,9 +306,8 @@ Example: } } -**Important**: Computed fields cannot be filtered at the database level -and will not appear in filter InputObjects. They are calculated in PHP -after data is retrieved from the database. +**Important**: A computed field is calculated in PHP after the rows are +fetched, so it is filtered and sorted only when it has an ``expression``. For more information, see `computed-fields `_. diff --git a/docs/computed-fields.rst b/docs/computed-fields.rst index bbec2aad..0b2e61ba 100644 --- a/docs/computed-fields.rst +++ b/docs/computed-fields.rst @@ -72,6 +72,11 @@ The ``#[ComputedField]`` attribute accepts these parameters: * ``name`` - Optional. Override the field name in the GraphQL schema. If not provided, the name is derived from the method name. * ``group`` - Optional. The attribute group (default: ``'default'``). +* ``expression`` - Optional. The DQL expression of the field's value, by which it is + filtered and sorted; see `Filters and Sorting`_. +* ``excludeFilters`` - Optional. Filters to exclude for a field with an ``expression``. +* ``includeFilters`` - Optional. The only filters to allow for a field with an + ``expression``. It is mutually exclusive with ``excludeFilters``. Custom Field Names ------------------ @@ -146,8 +151,9 @@ Computed fields are: with the regular fields, except those of fields with arguments, which have no one value * **Cached** - If you enable ``useHydratorCache``, a computed value is cached with the entity's other values for as long as the entity exists -* **Not filterable** - Computed fields cannot be used in database filters since they're - calculated in PHP, not at the database level +* **Filtered by an expression** - A computed field's value is calculated in PHP, so it is + filtered and sorted only when it has an ``expression``, its value in DQL; see + `Filters and Sorting`_ Arguments --------- @@ -259,12 +265,142 @@ pagination, filters or ``totalCount``, and it holds what the method returns. For a large collection, expose the association instead, whose rows the database filters and pages. -Filtering Limitations ---------------------- +Filters and Sorting +------------------- + +A computed field's value is calculated in PHP, after the rows are fetched, but +a filter must be applied by the database: the rows of a page, and its +``totalCount``, are counted and limited there. So a computed field is filtered +and sorted only when it has an ``expression``: the DQL expression of its value. +The field's filters, ``sort`` and ``sortPriority`` among them, then apply to the +expression as a field's apply to its column. + +.. code-block:: php + + use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL; + + #[GraphQL\Entity] + class Artist + { + #[GraphQL\ComputedField( + type: 'int', + expression: '(SELECT COUNT({p}.id) FROM App\ORM\Entity\Performance {p} WHERE {p}.artist = {entity})', + )] + public function getPerformanceCount(): int + { + return $this->performances->count(); + } + } + +.. code-block:: graphql + + { + artists (filter: { performanceCount: { gt: 0, sort: DESC } }) { + edges { node { name performanceCount } } + } + } + +The method still computes the value a query returns; the expression is used +only to filter and sort. They must compute the same value, or a filter will +disagree with the value shown, and a sorted list will appear out of order. + +Placeholders +~~~~~~~~~~~~ + +.. important:: + + **Every alias in an expression must be a placeholder**, written in braces: + ``{entity}`` for the field's own entity, and any other name, such as + ``{p}``, for an alias of the expression's own. + +DQL aliases must be unique across a query, subqueries included, and an +expression may be used more than once in one query: by two filters, such as +``{ gt: 0, lt: 5 }``, or by a filter and a sort. Each placeholder is given a +name unique in the query each time the expression is used, so its aliases +never collide with each other's, or with those of a QueryBuilder listener. An +alias written plainly, such as ``p``, works until the expression is used twice; +the check described below finds it. + +A placeholder is any ``{name}`` in the expression, including one within a +string literal. + +Arguments +~~~~~~~~~ + +``{:name}`` in an expression is the field's `argument <#arguments>`_ +``name``, bound as a parameter. A filter is applied before any field is +resolved, so its arguments cannot be those the field is given where it is +selected. The field's filters take them instead, in ``args``, of the +arguments the expression uses: + +.. code-block:: php + + #[GraphQL\ComputedField( + type: 'int', + expression: '(SELECT COUNT({r}.id) FROM App\ORM\Entity\Recording {r} ' + . 'WHERE {r}.artist = {entity} AND {r}.year >= {:year})', + )] + public function getRecordingCountSince(int $year): int + +.. code-block:: graphql + + { + artists (filter: { recordingCountSince: { args: { year: 2003 }, gt: 0 } }) { + edges { node { name recordingCountSince(year: 2003) } } + } + } -Computed fields do not appear in filter InputObjects because they cannot be filtered -at the database level. If you need to filter on computed values, consider storing -them in the database or using the `QueryBuilder Event `_ to add custom filters. +An argument not given in ``args`` is its default value, or null when it has +none. ``args`` is required when an argument the expression uses is. The +filter's ``args`` and the field's own arguments are independent: a filter may +use 2003 while the field shows 2004. + +Which Filters Apply +~~~~~~~~~~~~~~~~~~~ + +The filters of a computed field are those of its ``type``, less those +excluded by the configuration, by its entity, by the association whose +filters they are, and by its own ``excludeFilters`` or ``includeFilters``. + +DQL compares any expression, but takes a subquery, or an arithmetic +expression, for none of ``IN``, ``IS NULL`` and ``LIKE``. So the ``in``, +``notin``, ``isnull``, ``contains``, ``startswith`` and ``endswith`` filters +are each excluded for an expression DQL does not take them for, and added to +the field's ``excludeFilters`` in the metadata. ``between`` is applied as two +comparisons, so it applies to every expression. A function, such as +``UPPER({entity}.name)`` or ``SIZE({entity}.performances)``, takes every +filter. + +A sort orders by the expression selected as a hidden result variable, as DQL +does not order by a subquery. A `QueryBuilder listener `_ must +not replace the query's select, as ``->select()`` does, which would remove it. + +Checks +~~~~~~ + +Each expression is checked when the metadata is built, after the +``metadata.build`` event, and throws +``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata`` when: + +* it is not valid DQL, such as for a syntax error, an unknown field or class, + or an alias written without a placeholder. Doctrine's ``QueryException`` is + the previous exception. The expression is parsed, not run, so no database + is needed. +* it uses ``{:name}`` for a name which is not a parameter of the method. +* its field is a ``list``, or of an entity type, neither of which has a single + value to filter or sort by. +* a field without an ``expression`` has ``excludeFilters`` or + ``includeFilters``. + +Metadata read from a cache is not checked again. + +Costs +~~~~~ + +* A subquery is evaluated for each row the filters consider, and a sort by one + orders every row before the page is taken, without an index. For a large + table, store the value in a column instead. +* Databases order nulls differently, as they do for a field. Hydrator Strategies ------------------- diff --git a/docs/events.rst b/docs/events.rst index 0191aa51..e1601d9d 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -105,6 +105,11 @@ all resolve parameters: adds is fetched with Doctrine's ``Paginator``, which limits the entities rather than the rows. + A listener may add to the select, as ``->addSelect('p')`` does, but must + not replace it, as ``->select()`` does. A sort by a `computed field + `_ orders by a hidden result + variable in the select, which replacing it removes. + Association QueryBuilder Event ============================== diff --git a/docs/versions.rst b/docs/versions.rst index 15d2bf72..484661a2 100644 --- a/docs/versions.rst +++ b/docs/versions.rst @@ -59,6 +59,8 @@ ORM versions do not support ``doctrine/persistence`` 4. hydrator strategy - A computed field may be of an entity type, or a list of any type, and has an argument for each parameter of its method +- 14.1: a computed field with an ``expression``, its value in DQL, is filtered and sorted + by it; see `computed fields `_ - 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 8ba2d86e..dd46572e 100644 --- a/src/Attribute/ComputedField.php +++ b/src/Attribute/ComputedField.php @@ -4,6 +4,7 @@ namespace ApiSkeletons\Doctrine\ORM\GraphQL\Attribute; +use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; use Attribute; /** @@ -12,8 +13,11 @@ * Computed fields are generated from entity methods rather than database columns. * They integrate with the hydrator pipeline and are cached along with regular fields. * - * Important: Computed fields cannot be filtered at the database level and will not - * appear in filter InputObjects. + * A computed field is filtered and sorted only when it has an expression: the + * DQL expression of its value, which the filters compare and sort by. Every + * alias in it is a {placeholder}: {entity} is the entity, and any other is + * 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. * * @example * ```php @@ -30,15 +34,23 @@ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class ComputedField { + use ExcludeFilters; + /** - * @param string $type A type registered in the TypeContainer, or the class of an entity - * exposed in the group, whose type the field is - * @param string $group GraphQL schema group (default: 'default') - * @param string|null $name Field name in GraphQL schema. If null, derived from method name. - * @param string|null $description Field description for GraphQL schema - * @param bool $list Whether the method returns a list of the type - * @param array $args The registered type of each method parameter which is - * not an int, float, string or bool, by its name + * @param string $type A type registered in the TypeContainer, or the class of an entity + * exposed in the group, whose type the field is + * @param string $group GraphQL schema group (default: 'default') + * @param string|null $name Field name in GraphQL schema. If null, derived from method name. + * @param string|null $description Field description for GraphQL schema + * @param bool $list Whether the method returns a list of the type + * @param array $args The registered type of each method parameter which is + * not an int, float, string or bool, by its name + * @param string|null $expression The DQL expression of the field's value, by which it + * is filtered and sorted + * @param array $excludeFilters Filters cases or their values, for a field with an + * expression + * @param array $includeFilters Filters cases or their values, for a field with an + * expression */ public function __construct( private readonly string $type, @@ -47,6 +59,9 @@ public function __construct( private readonly string|null $description = null, private readonly bool $list = false, private readonly array $args = [], + private readonly string|null $expression = null, + private readonly array $excludeFilters = [], + private readonly array $includeFilters = [], ) { } @@ -80,4 +95,9 @@ public function getArgs(): array { return $this->args; } + + public function getExpression(): string|null + { + return $this->expression; + } } diff --git a/src/Filter/ComputedFieldExpression.php b/src/Filter/ComputedFieldExpression.php new file mode 100644 index 00000000..f7aad621 --- /dev/null +++ b/src/Filter/ComputedFieldExpression.php @@ -0,0 +1,76 @@ + + */ + public static function argumentNames(string $expression): array + { + preg_match_all(self::PLACEHOLDER, $expression, $matches, PREG_SET_ORDER); + + $names = []; + foreach ($matches as $match) { + if ($match[1] !== ':') { + continue; + } + + $names[$match[2]] = $match[2]; + } + + return array_values($names); + } + + /** + * The expression for one use: {entity} is the root alias, each other + * alias is prefixed, and each argument is the parameter the function + * returns for its name + * + * @param Closure(string): string $parameter The parameter, with its colon, of an argument + */ + public static function substitute( + string $expression, + string $rootAlias, + string $aliasPrefix, + Closure $parameter, + ): string { + return (string) preg_replace_callback( + self::PLACEHOLDER, + static fn (array $match): string => match (true) { + $match[1] === ':' => $parameter($match[2]), + $match[2] === self::ENTITY => $rootAlias, + default => $aliasPrefix . $match[2], + }, + $expression, + ); + } +} diff --git a/src/Filter/FilterFactory.php b/src/Filter/FilterFactory.php index 130fa24d..29121e75 100644 --- a/src/Filter/FilterFactory.php +++ b/src/Filter/FilterFactory.php @@ -14,11 +14,14 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManager; use GraphQL\Type\Definition\InputObjectType as GraphQLInputObjectType; +use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use ReflectionClass; use function array_filter; +use function array_flip; +use function array_intersect_key; use function array_merge; use function array_udiff; use function array_unique; @@ -26,6 +29,9 @@ use function assert; use function count; use function in_array; +use function md5; +use function serialize; +use function substr; use function ucwords; use const SORT_REGULAR; @@ -90,6 +96,8 @@ public function get( $fields = $this->addFields($targetEntity, $allowedFilters); /** @psalm-suppress MixedArgumentTypeCoercion */ $fields = array_merge($fields, $this->addAssociations($targetEntity, $allowedFilters)); + /** @psalm-suppress MixedArgumentTypeCoercion */ + $fields = array_merge($fields, $this->addComputedFields($targetEntity, $allowedFilters)); $inputObject = (new ReflectionClass(GraphQLInputObjectType::class)) ->newLazyGhost(static function (GraphQLInputObjectType $object) use ($typeName, $fields): void { @@ -245,6 +253,124 @@ protected function addAssociations(Entity $targetEntity, array $allowedFilters): return $fields; } + /** + * Add the filters of each computed field with an expression, by which it + * is filtered and sorted + * + * @param Filters[] $allowedFilters + * + * @return array + */ + protected function addComputedFields(Entity $targetEntity, array $allowedFilters): array + { + $fields = []; + + foreach ($targetEntity->getEntityMetadata()->computedFields as $fieldName => $computedField) { + if ($computedField->expression === null) { + continue; + } + + $type = $this->typeContainer->get($computedField->type); + + // A custom type may not be a scalar + if (! $type instanceof ScalarType || $type->name() === 'Blob') { + continue; + } + + // The computed field's own excludeFilters, or includeFilters, limit its filters + $excludeFilters = Filters::fromArray($computedField->excludeFilters); + $filters = $this->filterFiltersByType( + array_filter( + $allowedFilters, + static fn (Filters $filter): bool => ! in_array($filter, $excludeFilters, true), + ), + $type, + $computedField->type, + ); + + // An input object must have a field + if (! $filters) { + continue; + } + + $arguments = ComputedFieldExpression::argumentNames($computedField->expression); + + $fields[$fieldName] = [ + 'name' => $fieldName, + 'type' => $arguments === [] + ? $this->getFieldFilterType($type, $filters) + : $this->getComputedFieldFilterType($targetEntity, $fieldName, $type, $filters, $arguments), + 'description' => $type->name() . ' Filters', + ]; + } + + return $fields; + } + + /** + * The filter type of a computed field whose expression uses arguments: + * its filters and the args they are given, of the arguments the + * expression uses. The args are required when an argument is. + * + * @param Filters[] $filters + * @param list $arguments The arguments the expression uses + */ + private function getComputedFieldFilterType( + Entity $targetEntity, + string $fieldName, + ScalarType $type, + array $filters, + array $arguments, + ): GraphQLInputObjectType { + $filters = array_values($filters); + $baseName = $targetEntity->getTypeName() . '_' . $fieldName; + $name = 'Filters_' . $baseName . '_' . substr(md5(serialize($filters)), 0, 8); + + if ($this->typeContainer->has($name)) { + $filterType = $this->typeContainer->get($name); + assert($filterType instanceof GraphQLInputObjectType); + + return $filterType; + } + + $args = array_intersect_key($targetEntity->getComputedFieldArgs($fieldName), array_flip($arguments)); + + // An argument is required when it is not null and has no default + $required = false; + foreach ($args as $arg) { + $required = $required || ($arg['type'] instanceof NonNull && ! isset($arg['defaultValue'])); + } + + $argsName = 'FilterArgs_' . $baseName; + if (! $this->typeContainer->has($argsName)) { + $this->typeContainer->set($argsName, new GraphQLInputObjectType([ + 'name' => $argsName, + 'description' => 'The arguments of computed field ' . $fieldName . ' its filters use', + 'fields' => static fn () => $args, + ])); + } + + $argsType = $this->typeContainer->get($argsName); + assert($argsType instanceof GraphQLInputObjectType); + + $fields = Field::filterFields($this->typeContainer, $type, $filters); + $fields['args'] = [ + 'name' => 'args', + 'type' => $required ? Type::nonNull($argsType) : $argsType, + 'description' => 'The arguments of the computed field, for its filters', + ]; + + $filterType = new GraphQLInputObjectType([ // @phpstan-ignore argument.type + 'name' => $name, + 'description' => 'Computed field filters', + 'fields' => static fn () => $fields, + ]); + + $this->typeContainer->set($name, $filterType); + + return $filterType; + } + /** * The filter type of a field or association, shared by every field of the * same type and filters. It is named by a short hash of the filters; a diff --git a/src/Filter/InputObjectType/Field.php b/src/Filter/InputObjectType/Field.php index 52f8721b..27b6b9d4 100644 --- a/src/Filter/InputObjectType/Field.php +++ b/src/Filter/InputObjectType/Field.php @@ -25,7 +25,24 @@ public function __construct( readonly ScalarType $type, readonly array $allowedFilters, ) { - /** @var array> $fields */ + $fields = self::filterFields($typeContainer, $type, $allowedFilters); + + parent::__construct([ // @phpstan-ignore argument.type + 'name' => self::nameFor($type, $allowedFilters), + 'description' => 'Field filters', + 'fields' => static fn () => $fields, + ]); + } + + /** + * The fields of an input object of filters: one for each filter + * + * @param Filters[] $allowedFilters + * + * @return array> + */ + public static function filterFields(TypeContainer $typeContainer, ScalarType $type, array $allowedFilters): array + { $fields = []; foreach ($allowedFilters as $filter) { @@ -36,11 +53,7 @@ public function __construct( ]; } - parent::__construct([ // @phpstan-ignore argument.type - 'name' => self::nameFor($type, $allowedFilters), - 'description' => 'Field filters', - 'fields' => static fn () => $fields, - ]); + return $fields; } /** diff --git a/src/Filter/QueryBuilder.php b/src/Filter/QueryBuilder.php index dcd8423b..e0f672e5 100644 --- a/src/Filter/QueryBuilder.php +++ b/src/Filter/QueryBuilder.php @@ -5,6 +5,7 @@ namespace ApiSkeletons\Doctrine\ORM\GraphQL\Filter; use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Filter as FilterException; +use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata\ComputedFieldMetadata; use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\Entity; use DateInterval; use DateTime; @@ -18,6 +19,8 @@ use ReflectionClass; use function array_flip; +use function array_key_exists; +use function assert; use function in_array; use function is_array; use function is_int; @@ -98,6 +101,9 @@ final class QueryBuilder Filters::NOTIN, ]; + /** The filters which sort */ + private const array SORTS = [Filters::SORT, Filters::SORTPRIORITY]; + /** * The names of the types DBAL provides, keyed by name. Any other type * is a custom type. @@ -124,10 +130,15 @@ final class QueryBuilder /** The number of parameters named */ private int $parameterCount = 0; + /** The number of uses of computed field expressions, whose aliases are numbered by it */ + private int $expressionCount = 0; + /** * Add where clauses to a QueryBuilder based on the FilterType of the entity * * @param array> $filterTypes + * @param bool $sort Whether to apply the sorts. A count is + * not sorted. * * @psalm-suppress MixedAssignment, MixedArgument */ @@ -135,8 +146,18 @@ public function apply( array $filterTypes, DoctrineQueryBuilder $queryBuilder, Entity $entity, + bool $sort = true, ): void { foreach ($filterTypes as $fieldName => $filters) { + // A computed field is filtered by its expression. Each field of the + // type has a unique name, so a computed field is not a field's alias. + $computedField = $entity->getEntityMetadata()->computedFields[$fieldName] ?? null; + if ($computedField instanceof ComputedFieldMetadata && $computedField->expression !== null) { + $this->applyComputedField($fieldName, $computedField, $filters, $queryBuilder, $sort); + + continue; + } + // Resolve aliases $field = array_flip($entity->getExtractionMap())[$fieldName] ?? $fieldName; $queryBuilderField = 'entity.' . $field; @@ -148,6 +169,10 @@ public function apply( foreach ($filters as $filter => $value) { $filter = Filters::from($filter); + if (! $sort && in_array($filter, self::SORTS, true)) { + continue; + } + if (! $this->validateFilter($filter, $value, $fieldName, $fieldType, $valueType['unsigned'])) { continue; } @@ -155,7 +180,12 @@ public function apply( // A value compared to the field is its database value; a LIKE // pattern, a sort and isnull are not values of the field if (in_array($filter, self::COMPARISONS, true)) { - $value = $this->toDatabaseValue($value, $fieldType, $queryBuilder, $filter, $fieldName); + $value = $this->toDatabaseValue( + $value, + $fieldType, + $queryBuilder, + "Filter '" . $filter->value . "' of field '" . $fieldName . "'", + ); } $this->addFilter($filter, $queryBuilderField, $value, $queryBuilder); @@ -165,6 +195,142 @@ public function apply( $this->applySort($queryBuilder); } + /** + * Add the filters of a computed field, each comparing its expression. A + * sort orders by the expression, selected as a hidden result variable, as + * DQL does not order by a subquery. + * + * @param array $filters The filters, and the args of the field's arguments + * + * @psalm-suppress MixedAssignment, MixedArgument + */ + private function applyComputedField( + string $fieldName, + ComputedFieldMetadata $computedField, + array $filters, + DoctrineQueryBuilder $queryBuilder, + bool $sort, + ): void { + $args = $filters['args'] ?? []; + $args = is_array($args) ? $args : []; + unset($filters['args']); + + $fieldType = self::computedFieldValueType($computedField->type); + $sortAlias = null; + + foreach ($filters as $filter => $value) { + $filter = Filters::from($filter); + + // A sort's hidden select binds the parameters of the arguments it uses + if (! $sort && in_array($filter, self::SORTS, true)) { + continue; + } + + if (! $this->validateFilter($filter, $value, $fieldName, $fieldType, false)) { + continue; + } + + if (in_array($filter, self::SORTS, true)) { + if ($sortAlias === null) { + // Named by the use of the expression, so unique in the query + $expression = $this->expression($computedField, $args, $queryBuilder, $fieldName); + $sortAlias = 'computedSort' . $this->expressionCount; + $queryBuilder->addSelect($expression . ' AS HIDDEN ' . $sortAlias); + + $this->fieldNames[$sortAlias] = $fieldName; + } + + $this->addFilter($filter, $sortAlias, $value, $queryBuilder); + + continue; + } + + if (in_array($filter, self::COMPARISONS, true)) { + $value = $this->toDatabaseValue( + $value, + $fieldType, + $queryBuilder, + "Filter '" . $filter->value . "' of field '" . $fieldName . "'", + ); + } + + // DQL takes no subquery for BETWEEN, so it is applied as two comparisons + if ($filter === Filters::BETWEEN) { + assert(is_array($value)); + + $this->compare( + Filters::GTE, + $this->expression($computedField, $args, $queryBuilder, $fieldName), + $value['from'], + $queryBuilder, + ); + $this->compare( + Filters::LTE, + $this->expression($computedField, $args, $queryBuilder, $fieldName), + $value['to'], + $queryBuilder, + ); + + continue; + } + + $this->addFilter( + $filter, + $this->expression($computedField, $args, $queryBuilder, $fieldName), + $value, + $queryBuilder, + ); + } + } + + /** + * A computed field's expression for one use: its aliases are given names + * unique in the query, and each argument it uses is bound as a parameter, + * of its value in the args, else its default, else null + * + * @param array $args + */ + private function expression( + ComputedFieldMetadata $computedField, + array $args, + DoctrineQueryBuilder $queryBuilder, + string $fieldName, + ): string { + return ComputedFieldExpression::substitute( + (string) $computedField->expression, + $queryBuilder->getRootAliases()[0], + 'computed' . ++$this->expressionCount . '_', + function (string $name) use ($computedField, $args, $queryBuilder, $fieldName): string { + $argument = $computedField->args[$name]; + + /** @psalm-suppress MixedAssignment An argument may be of any type */ + $value = array_key_exists($name, $args) ? $args[$name] : $argument->default; + + $parameter = $this->parameter($queryBuilder); + $queryBuilder->setParameter($parameter, $this->toDatabaseValue( + $value, + self::computedFieldValueType($argument->type), + $queryBuilder, + "Argument '" . $name . "' of the filter of field '" . $fieldName . "'", + )); + + return ':' . $parameter; + }, + ); + } + + /** + * The Doctrine type of a computed field's values, or of an argument's: its + * registered type, whose name is a Doctrine type's, but for int, which is + * integer. A type which is not a Doctrine type is bound as it is. + */ + private static function computedFieldValueType(string $type): string|null + { + $type = $type === 'int' ? Types::INTEGER : $type; + + return Type::hasType($type) ? $type : null; + } + /** * Whether a filter is applied. A filter given null is not, as a field or * filter given null is not, and neither is notin given an empty list. @@ -498,20 +664,21 @@ private function getValueType(DoctrineQueryBuilder $queryBuilder, Entity $entity * entity it refers to. The value of a type DBAL provides is bound as it * is. * + * @param string $subject The filter or argument given the value, for an error + * * @throws FilterException When a custom type cannot convert the value. */ private function toDatabaseValue( mixed $value, string|null $fieldType, DoctrineQueryBuilder $queryBuilder, - Filters $filter, - string $fieldName, + string $subject, ): mixed { if (is_array($value)) { // Filter values are GraphQL input, so their elements are mixed /** @psalm-suppress MixedAssignment */ foreach ($value as $key => $item) { - $value[$key] = $this->toDatabaseValue($item, $fieldType, $queryBuilder, $filter, $fieldName); + $value[$key] = $this->toDatabaseValue($item, $fieldType, $queryBuilder, $subject); } return $value; @@ -541,9 +708,7 @@ private function toDatabaseValue( try { return Type::getType($fieldType)->convertToDatabaseValue($value, $platform); } catch (ConversionException) { - throw new FilterException( - "Filter '" . $filter->value . "' of field '" . $fieldName . "' is given a value which is not valid.", - ); + throw new FilterException($subject . ' is given a value which is not valid.'); } } diff --git a/src/Metadata/ComputedFieldMetadata.php b/src/Metadata/ComputedFieldMetadata.php index a3c69ac5..d06f9ee9 100644 --- a/src/Metadata/ComputedFieldMetadata.php +++ b/src/Metadata/ComputedFieldMetadata.php @@ -13,7 +13,13 @@ */ final readonly class ComputedFieldMetadata { - /** @param array $args The arguments, by name: the method's parameters */ + /** + * @param array $args The arguments, by name: the method's + * parameters + * @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 + */ public function __construct( public string $method, public string $type, @@ -21,6 +27,8 @@ public function __construct( public string|null $description, public bool $list, public array $args, + public string|null $expression = null, + public array $excludeFilters = [], ) { } @@ -45,13 +53,20 @@ public static function fromArray(array $array, string $context): self $reader->nullableString('description'), $reader->bool('list'), $args, + $reader->has('expression') ? $reader->string('expression') : null, + $reader->has('excludeFilters') ? $reader->stringList('excludeFilters') : [], ); } - /** @return array{method: string, type: string, name: string, description: string|null, list: bool, args: array} */ + /** + * 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} + */ public function toArray(): array { - return [ + $array = [ 'method' => $this->method, 'type' => $this->type, 'name' => $this->name, @@ -62,5 +77,15 @@ public function toArray(): array $this->args, ), ]; + + if ($this->expression !== null) { + $array['expression'] = $this->expression; + } + + if ($this->excludeFilters !== []) { + $array['excludeFilters'] = $this->excludeFilters; + } + + return $array; } } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 2075cbf6..05016923 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -8,12 +8,14 @@ use ApiSkeletons\Doctrine\ORM\GraphQL\Config; use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Metadata as MetadataEvent; use ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata as MetadataException; +use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\ComputedFieldExpression; use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy; use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata; use ApiSkeletons\Doctrine\ORM\GraphQL\Trait\FindPropertyInHierarchy; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\QueryException; use League\Event\EventDispatcher; use ReflectionClass; use ReflectionMethod; @@ -23,6 +25,8 @@ use function array_key_exists; use function array_keys; +use function array_unique; +use function array_values; use function assert; use function ctype_upper; use function in_array; @@ -43,6 +47,17 @@ final class MetadataFactory { use FindPropertyInHierarchy; + /** + * The DQL of the filters which DQL applies to some expressions only, by + * the forms they use. A comparison, and so a between, which is applied as + * two, applies to every expression. + */ + private const array EXPRESSION_FILTER_FORMS = [ + ' IN (:validateList)' => [Filters::IN, Filters::NOTIN], + ' IS NULL' => [Filters::ISNULL], + " LIKE :validateValue ESCAPE '!'" => [Filters::CONTAINS, Filters::STARTSWITH, Filters::ENDSWITH], + ]; + public function __construct( protected Metadata $metadata, protected readonly EntityManager $entityManager, @@ -96,6 +111,7 @@ public function getMetadata(): Metadata // After the event, as a listener may add or remove entities $this->assertReferencedEntitiesAreExposed($entityClasses); + $this->assertComputedFieldExpressionsAreValid($entityClasses); return $this->metadata; } @@ -342,6 +358,16 @@ private function buildMetadataForComputedFields(ReflectionClass $reflectionClass 'args' => $this->buildComputedFieldArguments($reflectionMethod, $instance, $reflectionClass->getName()), ]; + // Left out when unset, so the metadata of a field without them is unchanged + if ($instance->getExpression() !== null) { + $computedFieldMetadata['expression'] = $instance->getExpression(); + } + + $excludeFilters = Filters::toStringArray($instance->getExcludeFilters()); + if ($excludeFilters !== []) { + $computedFieldMetadata['excludeFilters'] = $excludeFilters; + } + /** @psalm-suppress MixedArrayAssignment */ $this->metadata[$reflectionClass->getName()]['computedFields'][$fieldName] = $computedFieldMetadata; } @@ -494,6 +520,160 @@ private function assertReferencedEntitiesAreExposed(array $entityClasses): void } } + /** + * 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 + * rather than when a client's query uses it. The excluded filters of a + * computed field apply only to one with an expression. + * + * DQL compares any expression, but takes a subquery, or an arithmetic + * expression, for none of IN, IS NULL and LIKE. The filters which use + * them are excluded for an expression they cannot be applied to, so the + * metadata, cached or not, has only the filters which apply. + * + * @param list $entityClasses Every entity class of the entity manager + * + * @throws MetadataException + */ + private function assertComputedFieldExpressionsAreValid(array $entityClasses): void + { + // The excluded filters of each field, by entity, set after the iteration + $excludeFilters = []; + + /** @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)); + + /** @psalm-suppress MixedAssignment The metadata built above */ + foreach ($computed as $fieldName => $computedFieldArray) { + if ( + ! is_array($computedFieldArray) + || (! array_key_exists('expression', $computedFieldArray) + && ! array_key_exists('excludeFilters', $computedFieldArray)) + ) { + continue; + } + + $computedField = ComputedFieldMetadata::fromArray( + $computedFieldArray, + 'entity ' . $entityClass . ' computed field ' . $fieldName, + ); + + $unsupported = $this->assertComputedFieldExpressionIsValid( + $entityClass, + (string) $fieldName, + $computedField, + $entityClasses, + ); + + $excluded = array_values(array_unique([...$computedField->excludeFilters, ...$unsupported])); + if ($excluded === $computedField->excludeFilters) { + continue; + } + + $excludeFilters[$entityClass][$fieldName] = $excluded; + } + } + + foreach ($excludeFilters as $entityClass => $fields) { + /** @psalm-suppress MixedAssignment The metadata built above */ + $entityMetadata = $this->metadata[$entityClass]; + assert(is_array($entityMetadata) && is_array($entityMetadata['computedFields'])); + + foreach ($fields as $fieldName => $excluded) { + assert(is_array($entityMetadata['computedFields'][$fieldName])); + + /** @psalm-suppress MixedArrayAssignment */ + $entityMetadata['computedFields'][$fieldName]['excludeFilters'] = $excluded; + } + + $this->metadata[$entityClass] = $entityMetadata; + } + } + + /** + * An expression is the value of a single scalar, uses only the field's + * arguments, and is valid DQL. It is parsed, without a database, in a + * query which uses it twice, as a filter and as a sort, as a query may: + * an alias written without a placeholder is then defined twice. Returns + * the filters DQL cannot apply to it. + * + * @param list $entityClasses Every entity class of the entity manager + * + * @return list + * + * @throws MetadataException + */ + private function assertComputedFieldExpressionIsValid( + string $entityClass, + string $fieldName, + ComputedFieldMetadata $computedField, + array $entityClasses, + ): array { + $prefix = 'Computed field ' . $fieldName . ' of entity ' . $entityClass; + $expression = $computedField->expression; + + if ($expression === null) { + throw new MetadataException( + $prefix . ' has excluded filters but no expression. A computed field is filtered only by its ' + . 'expression.', + ); + } + + if ($computedField->list || in_array($computedField->type, $entityClasses, true)) { + throw new MetadataException( + $prefix . ' has an expression but is ' . ($computedField->list ? 'a list' : 'of an entity type') + . ', which has no single value to filter or sort by.', + ); + } + + foreach (ComputedFieldExpression::argumentNames($expression) as $name) { + if (! isset($computedField->args[$name])) { + throw new MetadataException( + $prefix . ' has an expression which uses {:' . $name . '}, but ' . $name . ' is not a parameter ' + . 'of method ' . $computedField->method . '.', + ); + } + } + + $use = static fn (string $aliasPrefix): string => ComputedFieldExpression::substitute( + $expression, + 'entity', + $aliasPrefix, + static fn (string $name): string => ':' . $aliasPrefix . $name, + ); + + $select = 'SELECT entity FROM ' . $entityClass . ' entity WHERE '; + $dql = 'SELECT entity, ' . $use('validate1_') . ' AS HIDDEN validateSort FROM ' . $entityClass . ' entity ' + . 'WHERE ' . $use('validate2_') . ' = :validateValue ORDER BY validateSort'; + + try { + $this->entityManager->createQuery($dql)->getAST(); + } catch (QueryException $exception) { + throw new MetadataException( + $prefix . ' has an expression which is not valid: ' . $exception->getMessage() . ' Every alias in ' + . 'an expression must be a placeholder, such as {entity} or {p}.', + previous: $exception, + ); + } + + $unsupported = []; + foreach (self::EXPRESSION_FILTER_FORMS as $form => $filters) { + try { + $this->entityManager->createQuery($select . $use('validate3_') . $form)->getAST(); + } catch (QueryException) { + $unsupported = [...$unsupported, ...Filters::toStringArray($filters)]; + } + } + + return $unsupported; + } + /** * A Field attribute of the group must be on a mapped field, and an * Association attribute on an association, of the entity or a parent diff --git a/src/Resolve/ResolveCollectionFactory.php b/src/Resolve/ResolveCollectionFactory.php index dc29d176..99588bc4 100644 --- a/src/Resolve/ResolveCollectionFactory.php +++ b/src/Resolve/ResolveCollectionFactory.php @@ -17,6 +17,7 @@ use Closure; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\Expr\Select; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator; use GraphQL\Deferred; @@ -36,6 +37,7 @@ use function is_object; use function is_string; use function serialize; +use function str_contains; /** * Build a resolver for collections @@ -349,11 +351,17 @@ private function fetchBatchTargetIds(CollectionBatch $batch): array|null $queryBuilder = $this->createBatchQueryBuilder($batch, $chunk, $parent); $this->orderByAssociation($queryBuilder, $batch->sourceClassName, $batch->associationName); $this->orderByIdentifier($queryBuilder); + $hiddenSelects = $this->getHiddenSelects($queryBuilder); $queryBuilder ->select($parent['select'] . ' AS parent') ->addSelect('entity.' . $targetId . ' AS target') ->setMaxResults($remaining + 1); + // A sort by a computed field orders by its hidden select + foreach ($hiddenSelects as $hiddenSelect) { + $queryBuilder->addSelect($hiddenSelect); + } + /** @var array $rows */ $rows = $queryBuilder->getQuery()->getScalarResult(); @@ -371,6 +379,31 @@ private function fetchBatchTargetIds(CollectionBatch $batch): array|null return $targetIds; } + /** + * The hidden result variables a query selects, which a sort by a computed + * field orders by + * + * @return list + */ + private function getHiddenSelects(QueryBuilder $queryBuilder): array + { + $hiddenSelects = []; + + /** @var list