partkeepr

fork of partkeepr
git clone https://git.e1e0.net/partkeepr.git
Log | Files | Refs | Submodules | README | LICENSE

commit a5d0163b706ee93bf19cb0dd09647d9af046e78a
parent 6c839ccc557462e628bbb6a56b447d5ec17aa4cb
Author: Felicitus <felicitus@felicitus.org>
Date:   Thu, 16 Jul 2015 21:26:06 +0200

Added an advanced search filter for the DunglasAPIBundle

Diffstat:
Asrc/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/PartKeepr/DoctrineReflectionBundle/Resources/config/services.xml | 7+++++++
2 files changed, 296 insertions(+), 0 deletions(-)

diff --git a/src/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php b/src/PartKeepr/DoctrineReflectionBundle/Filter/AdvancedSearchFilter.php @@ -0,0 +1,289 @@ +<?php +namespace PartKeepr\DoctrineReflectionBundle\Filter; + +use Doctrine\Common\Persistence\ManagerRegistry; +use Doctrine\ORM\QueryBuilder; +use Dunglas\ApiBundle\Api\IriConverterInterface; +use Dunglas\ApiBundle\Api\ResourceInterface; +use Dunglas\ApiBundle\Doctrine\Orm\Filter\AbstractFilter; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +/** + * Class AdvancedSearchFilter + * + * Allows filtering by different operators and nested associations. Expects a query parameter "filter" which includes + * JSON in the following format: + * + * [{ + * property: 'comments.authors.name', + * operator: 'LIKE', + * value: '%heiner%' + * }] + * + * You can also specify multiple filters with different operators and values. + */ +class AdvancedSearchFilter extends AbstractFilter +{ + /** + * @var IriConverterInterface + */ + private $iriConverter; + + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + const OPERATOR_LESS_THAN = "<"; + const OPERATOR_GREATER_THAN = ">"; + const OPERATOR_EQUALS = "="; + const OPERATOR_GREATER_THAN_EQUALS = ">="; + const OPERATOR_LESS_THAN_EQUALS = ">="; + const OPERATOR_NOT_EQUALS = "!="; + const OPERATOR_IN = "in"; + const OPERATOR_LIKE = "like"; + + const OPERATORS = array( + self::OPERATOR_LESS_THAN, + self::OPERATOR_GREATER_THAN, + self::OPERATOR_EQUALS, + self::OPERATOR_GREATER_THAN_EQUALS, + self::OPERATOR_LESS_THAN_EQUALS, + self::OPERATOR_NOT_EQUALS, + self::OPERATOR_IN, + self::OPERATOR_LIKE, + ); + + private $aliases = array(); + + private $parameterCount = 0; + + private $joins = array(); + + /** + * @param ManagerRegistry $managerRegistry + * @param IriConverterInterface $iriConverter + * @param PropertyAccessorInterface $propertyAccessor + * @param null|array $properties Null to allow filtering on all properties with the exact strategy + * or a map of property name with strategy. + */ + public function __construct( + ManagerRegistry $managerRegistry, + IriConverterInterface $iriConverter, + PropertyAccessorInterface $propertyAccessor, + array $properties = null + ) { + parent::__construct($managerRegistry, $properties); + + $this->iriConverter = $iriConverter; + $this->propertyAccessor = $propertyAccessor; + } + + public function getDescription(ResourceInterface $resource) + { + return array(); + } + + /** + * {@inheritdoc} + */ + public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, Request $request) + { + $metadata = $this->getClassMetadata($resource); + $fieldNames = array_flip($metadata->getFieldNames()); + + $filters = $this->extractProperties($request); + + foreach ($filters as $filter) { + if (isset($fieldNames[$filter["property"]]) && $filter["association"] === null) { + $queryBuilder + ->andWhere( + $this->getFilterExpression($queryBuilder, $filter) + ); + + } else { + // Pull in associations + $this->addJoins($queryBuilder, $filter); + + $queryBuilder->andWhere( + $this->getFilterExpression($queryBuilder, $filter) + ); + } + } + } + + /** + * Adds all required joins to the queryBuilder. + * + * @param QueryBuilder $queryBuilder + * @param $filter + */ + private function addJoins(QueryBuilder $queryBuilder, $filter) + { + if (in_array($filter["association"], $this->joins)) { + // Association already added, return + return; + } + + $associations = explode(".", $filter["association"]); + + $fullAssociation = "o"; + + foreach ($associations as $key => $association) { + if (isset($associations[$key - 1])) { + $parent = $associations[$key - 1]; + } else { + $parent = "o"; + } + + $fullAssociation .= ".".$association; + + $alias = $this->getAlias($fullAssociation); + + $queryBuilder->join($parent.".".$association, $alias); + } + + $this->joins[] = $filter["association"]; + } + + /** + * Returns the expression for a specific filter. + * @param QueryBuilder $queryBuilder + * @param $filter + * + * @return \Doctrine\ORM\Query\Expr\Comparison|\Doctrine\ORM\Query\Expr\Func + * @throws \Exception + */ + private function getFilterExpression(QueryBuilder $queryBuilder, $filter) + { + if ($filter["association"] !== null) { + $alias = $this->getAlias("o.".$filter["association"]).".".$filter["property"]; + } else { + $alias = "o.".$filter["property"]; + } + + if ($filter["operator"] == self::OPERATOR_IN) { + if (!is_array($filter["value"])) { + throw new \Exception("Value needs to be an array for the IN operator"); + } + + return $queryBuilder->expr()->in($alias, $filter["value"]); + } else { + $paramName = ":param".$this->parameterCount; + $this->parameterCount++; + $queryBuilder->setParameter($paramName, $filter["value"]); + + switch ($filter["operator"]) { + case self::OPERATOR_EQUALS: + return $queryBuilder->expr()->eq($alias, $paramName); + break; + case self::OPERATOR_GREATER_THAN: + return $queryBuilder->expr()->gt($alias, $paramName); + break; + case self::OPERATOR_GREATER_THAN_EQUALS: + return $queryBuilder->expr()->gte($alias, $paramName); + break; + case self::OPERATOR_LESS_THAN: + return $queryBuilder->expr()->lt($alias, $paramName); + break; + case self::OPERATOR_LESS_THAN_EQUALS: + return $queryBuilder->expr()->lte($alias, $paramName); + break; + case self::OPERATOR_NOT_EQUALS: + return $queryBuilder->expr()->neq($alias, $paramName); + break; + case self::OPERATOR_LIKE: + return $queryBuilder->expr()->like($alias, $paramName); + break; + default: + throw new \Exception("Unknown filter"); + } + } + } + + /** + * {@inheritdoc} + */ + protected function extractProperties(Request $request) + { + $filters = array(); + + if ($request->query->has("filter")) { + $data = json_decode($request->query->get("filter")); + + if (is_array($data)) { + foreach ($data as $filter) { + $filters[] = $this->extractJSONFilters($filter); + } + } elseif (is_object($data)) { + $filters[] = $this->extractJSONFilters($data); + } + } + + return $filters; + } + + /** + * Returns an alias for the given association property. + * + * @param string $property The property in FQDN format, e.g. "comments.authors.name" + * + * @return string The table alias + */ + private function getAlias($property) + { + if (!array_key_exists($property, $this->aliases)) { + $this->aliases[$property] = "t".count($this->aliases); + } + + return $this->aliases[$property]; + } + + /** + * Extracts the filters from the JSON object. + * + * @param $data + * + * @return array An array containing the property, operator and value keys + * @throws \Exception + */ + private function extractJSONFilters($data) + { + $filter = array(); + + if ($data->property) { + if (strpos($data->property, ".") !== false) { + $associations = explode(".", $data->property); + + $property = array_pop($associations); + + $filter["association"] = implode(".", $associations); + $filter["property"] = $property; + } else { + $filter["association"] = null; + $filter["property"] = $data->property; + } + + } else { + throw new \Exception("You need to set the filter property"); + } + + if ($data->operator) { + if (!in_array($data->operator, self::OPERATORS)) { + throw new \Exception(sprintf("Invalid operator %s", $data->operator)); + } + $filter["operator"] = $data->operator; + } else { + $filter["operator"] = self::OPERATOR_EQUALS; + } + + if ($data->value) { + $filter["value"] = $data->value; + } else { + throw new \Exception("No value specified"); + } + + return $filter; + } +} diff --git a/src/PartKeepr/DoctrineReflectionBundle/Resources/config/services.xml b/src/PartKeepr/DoctrineReflectionBundle/Resources/config/services.xml @@ -10,5 +10,12 @@ <argument type="service" id="templating"/> <argument type="service" id="annotation_reader"/> </service> + + <service id="doctrine_reflection_service.search_filter" class="PartKeepr\DoctrineReflectionBundle\Filter\AdvancedSearchFilter" public="false"> + <argument type="service" id="doctrine" /> + <argument type="service" id="api.iri_converter" /> + <argument type="service" id="property_accessor" /> + </service> + </services> </container>