From 00708608cd4bb995e6431c455be854b28419e91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Oct 2023 21:37:58 +0200 Subject: [PATCH] Added entity filter to filter part response by categories, etc. --- docs/api/intro.md | 11 ++ src/ApiPlatform/Filter/EntityFilter.php | 148 ++++++++++++++++++++++++ src/Entity/Parts/Part.php | 4 +- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/ApiPlatform/Filter/EntityFilter.php diff --git a/docs/api/intro.md b/docs/api/intro.md index 94d410ab..aca4bc49 100644 --- a/docs/api/intro.md +++ b/docs/api/intro.md @@ -96,6 +96,17 @@ whose name starts with "BC", you can use `/api/parts.jsonld?name=BC%25` (the `%2 There are other filters available for some entities, allowing you to search on other fields, or restricting the results by numeric values or dates. See the endpoint documentation for the available filters. +## Filter by associated entities + +To get all parts with a certain category, manufacturer, etc. you can use the `category`, `manufacturer`, etc. query parameters of the `/api/parts` endpoint. +They are so called entitiy filters and accept a comma separated list of IDs of the entities you want to filter by. +For example if you want to get all parts with the category "Resistor" (Category ID 1) and "Capacitor" (Category ID 2), you can use `/api/parts.jsonld?category=1,2`. + +Suffix an id with `+` to suffix, to include all direct children categories of the given category. Use the `++` suffix to include all children categories recursively. +To get all parts with the category "Resistor" (Category ID 1) and all children categories of "Capacitor" (Category ID 2), you can use `/api/parts.jsonld?category=1,2++`. + +See the endpoint documentation for the available entity filters. + ## Ordering results When retrieving a list of entities, you can order the results by various fields using the `order` query parameter. diff --git a/src/ApiPlatform/Filter/EntityFilter.php b/src/ApiPlatform/Filter/EntityFilter.php new file mode 100644 index 00000000..a8b250ad --- /dev/null +++ b/src/ApiPlatform/Filter/EntityFilter.php @@ -0,0 +1,148 @@ +. + */ + +declare(strict_types=1); + + +namespace App\ApiPlatform\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Services\Trees\NodesListBuilder; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Log\LoggerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +class EntityFilter extends AbstractFilter +{ + + public function __construct( + ManagerRegistry $managerRegistry, + private NodesListBuilder $nodesListBuilder, + private EntityManagerInterface $entityManager, + LoggerInterface $logger = null, + ?array $properties = null, + ?NameConverterInterface $nameConverter = null + ) { + parent::__construct($managerRegistry, $logger, $properties, $nameConverter); + } + + protected function filterProperty( + string $property, + $value, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + Operation $operation = null, + array $context = [] + ): void { + if ( + !$this->isPropertyEnabled($property, $resourceClass) || + !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return; + } + + $metadata = $this->getClassMetadata($resourceClass); + $target_class = $metadata->getAssociationTargetClass($property); + //If it is not an association we can not filter the property + if (!$target_class) { + return; + } + + $elements = $this->valueToEntityArray($value, $target_class); + + $parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters + $queryBuilder + ->andWhere(sprintf('o.%s IN (:%s)', $property, $parameterName)) + ->setParameter($parameterName, $elements); + } + + private function valueToEntityArray(string $value, string $target_class): array + { + //Convert value to IDs: + $elements = []; + + //Split the given value by comm + foreach (explode(',', $value) as $id) { + if (trim($id) === '') { + continue; + } + + //Check if the given value ends with a plus, then we want to include all direct children + $include_children = false; + $include_recursive = false; + if (str_ends_with($id, '++')) { //Plus Plus means include all children recursively + $id = substr($id, 0, -2); + $include_recursive = true; + } elseif (str_ends_with($id, '+')) { + $id = substr($id, 0, -1); + $include_children = true; + } + + //Get a (shallow) reference to the entitity + $element = $this->entityManager->getReference($target_class, (int) $id); + $elements[] = $element; + + //If $element is not structural we are done + if (!is_a($element, AbstractStructuralDBElement::class)) { + continue; + } + + //Get the recursive list of children + if ($include_recursive) { + $elements = array_merge($elements, $this->nodesListBuilder->getChildrenFlatList($element)); + } elseif ($include_children) { + $elements = array_merge($elements, $element->getChildren()->toArray()); + } + } + + return $elements; + } + + public function getDescription(string $resourceClass): array + { + if (!$this->properties) { + return []; + } + + $description = []; + foreach ($this->properties as $property => $strategy) { + $description["$property"] = [ + 'property' => $property, + 'type' => Type::BUILTIN_TYPE_STRING, + 'required' => false, + 'description' => 'Filter using a comma seperated list of element IDs. Use + to include all direct children and ++ to include all children recursively.', + 'openapi' => [ + 'example' => '', + 'allowReserved' => false,// if true, query parameters will be not percent-encoded + 'allowEmptyValue' => true, + 'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green + ], + ]; + } + return $description; + } +} \ No newline at end of file diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 1a52490f..aa4a9a17 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -37,6 +37,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\DocumentedAPIProperty; +use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Repository\PartRepository; @@ -91,8 +92,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; denormalizationContext: ['groups' => ['part:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] +#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "tags", "manufacturer_product_number"])] -#[ApiFilter(BooleanFilter::class, properties: ["favorite" ])] +#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])] #[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])] #[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)] #[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]