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