AdvancedSearchFilter.php (12920B)
1 <?php 2 3 namespace PartKeepr\DoctrineReflectionBundle\Filter; 4 5 use Doctrine\Common\Persistence\ManagerRegistry; 6 use Doctrine\ORM\QueryBuilder; 7 use Dunglas\ApiBundle\Api\IriConverterInterface; 8 use Dunglas\ApiBundle\Api\ResourceInterface; 9 use Dunglas\ApiBundle\Doctrine\Orm\Filter\AbstractFilter; 10 use PartKeepr\DoctrineReflectionBundle\Services\FilterService; 11 use Symfony\Component\HttpFoundation\RequestStack; 12 use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 13 14 /** 15 * Class AdvancedSearchFilter. 16 * 17 * Allows filtering by different operators and nested associations. Expects a query parameter "filter" which includes 18 * JSON in the following format: 19 * 20 * [{ 21 * property: 'comments.authors.name', 22 * operator: 'LIKE', 23 * value: '%heiner%' 24 * }] 25 * 26 * You can also specify multiple filters with different operators and values. 27 */ 28 class AdvancedSearchFilter extends AbstractFilter 29 { 30 /** 31 * @var IriConverterInterface 32 */ 33 private $iriConverter; 34 35 /** 36 * @var PropertyAccessorInterface 37 */ 38 private $propertyAccessor; 39 40 /** 41 * @var FilterService 42 */ 43 private $filterService; 44 45 private $aliases = []; 46 47 private $parameterCount = 0; 48 49 private $requestStack; 50 51 private $joins = []; 52 53 /** 54 * @param ManagerRegistry $managerRegistry 55 * @param IriConverterInterface $iriConverter 56 * @param PropertyAccessorInterface $propertyAccessor 57 * @param RequestStack $requestStack 58 * @param null|array $properties Null to allow filtering on all properties with the exact 59 * strategy or a map of property name with strategy. 60 */ 61 public function __construct( 62 ManagerRegistry $managerRegistry, 63 IriConverterInterface $iriConverter, 64 PropertyAccessorInterface $propertyAccessor, 65 RequestStack $requestStack, 66 FilterService $filterService, 67 array $properties = null 68 ) { 69 parent::__construct($managerRegistry, $properties); 70 $this->requestStack = $requestStack; 71 $this->iriConverter = $iriConverter; 72 $this->filterService = $filterService; 73 $this->propertyAccessor = $propertyAccessor; 74 } 75 76 public function getDescription(ResourceInterface $resource) 77 { 78 return []; 79 } 80 81 /** 82 * {@inheritdoc} 83 */ 84 public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder) 85 { 86 $request = $this->requestStack->getCurrentRequest(); 87 if (null === $request) { 88 return; 89 } 90 91 if ($request->query->has('filter')) { 92 $filter = json_decode($request->query->get("filter")); 93 } else { 94 $filter = null; 95 } 96 97 if ($request->query->has('order')) { 98 $order = json_decode($request->query->get("order")); 99 } else { 100 $order = null; 101 } 102 103 $properties = $this->extractConfiguration($filter, $order); 104 105 $filters = $properties['filters']; 106 $sorters = $properties['sorters']; 107 108 $this->filter($queryBuilder, $filters, $sorters); 109 } 110 111 public function filter(QueryBuilder $queryBuilder, $filters, $sorters) 112 { 113 foreach ($filters as $filter) { 114 /** 115 * @var Filter 116 */ 117 $queryBuilder->andWhere( 118 $this->getFilterExpression($queryBuilder, $filter) 119 ); 120 } 121 122 foreach ($sorters as $sorter) { 123 /** 124 * @var Sorter 125 */ 126 if ($sorter->getAssociation() !== null) { 127 // Pull in associations 128 $this->addJoins($queryBuilder, $sorter); 129 } 130 131 $this->applyOrderByExpression($queryBuilder, $sorter); 132 } 133 } 134 135 /** 136 * Gets the ID from an URI or a raw ID. 137 * 138 * @param string $value 139 * 140 * @return string 141 */ 142 private function getFilterValueFromUrl($value) 143 { 144 if (is_array($value)) { 145 $items = []; 146 147 foreach ($value as $iri) { 148 try { 149 if ($item = $this->iriConverter->getItemFromIri($iri)) { 150 $items[] = $this->propertyAccessor->getValue($item, 'id'); 151 } else { 152 $items[] = $iri; 153 } 154 } catch (\InvalidArgumentException $e) { 155 $items[] = $iri; 156 } 157 } 158 159 return $items; 160 } 161 162 try { 163 if ($item = $this->iriConverter->getItemFromIri($value)) { 164 return $this->propertyAccessor->getValue($item, 'id'); 165 } 166 } catch (\InvalidArgumentException $e) { 167 // Do nothing, return the raw value 168 } 169 170 return $value; 171 } 172 173 /** 174 * Adds all required joins to the queryBuilder. 175 * 176 * @param QueryBuilder $queryBuilder 177 * @param $filter 178 */ 179 private function addJoins(QueryBuilder $queryBuilder, AssociationPropertyInterface $filter) 180 { 181 if (in_array($filter->getAssociation(), $this->joins)) { 182 // Association already added, return 183 return; 184 } 185 186 $associations = explode('.', $filter->getAssociation()); 187 188 $fullAssociation = 'o'; 189 190 foreach ($associations as $key => $association) { 191 if (isset($associations[$key - 1])) { 192 $parent = $associations[$key - 1]; 193 } else { 194 $parent = 'o'; 195 } 196 197 $fullAssociation .= '.'.$association; 198 199 $alias = $this->getAlias($fullAssociation); 200 201 $queryBuilder->join($parent.'.'.$association, $alias); 202 } 203 204 $this->joins[] = $filter->getAssociation(); 205 } 206 207 /** 208 * Returns the expression for a specific filter. 209 * 210 * @param QueryBuilder $queryBuilder 211 * @param $filter 212 * 213 * @throws \Exception 214 * 215 * @return \Doctrine\ORM\Query\Expr\Comparison|\Doctrine\ORM\Query\Expr\Func 216 */ 217 private function getFilterExpression(QueryBuilder $queryBuilder, Filter $filter) 218 { 219 if ($filter->hasSubFilters()) { 220 $subFilterExpressions = []; 221 222 foreach ($filter->getSubFilters() as $subFilter) { 223 224 /** 225 * @var Filter 226 */ 227 if ($subFilter->getAssociation() !== null) { 228 $this->addJoins($queryBuilder, $subFilter); 229 } 230 231 $subFilterExpressions[] = $this->getFilterExpression($queryBuilder, $subFilter); 232 } 233 234 if ($filter->getType() == Filter::TYPE_AND) { 235 return call_user_func_array([$queryBuilder->expr(), "andX"], $subFilterExpressions); 236 } else { 237 return call_user_func_array([$queryBuilder->expr(), "orX"], $subFilterExpressions); 238 } 239 } 240 241 if ($filter->getAssociation() !== null) { 242 $this->addJoins($queryBuilder, $filter); 243 $alias = $this->getAlias('o.'.$filter->getAssociation()).'.'.$filter->getProperty(); 244 } else { 245 $alias = 'o.'.$filter->getProperty(); 246 } 247 248 if (strtolower($filter->getOperator()) == Filter::OPERATOR_IN) { 249 if (!is_array($filter->getValue())) { 250 throw new \Exception('Value needs to be an array for the IN operator'); 251 } 252 253 return $queryBuilder->expr()->in($alias, $filter->getValue()); 254 } else { 255 $paramName = ':param'.$this->parameterCount; 256 $this->parameterCount++; 257 $queryBuilder->setParameter($paramName, $filter->getValue()); 258 259 return $this->filterService->getExpressionForFilter($filter, $alias, $paramName); 260 } 261 } 262 263 /** 264 * Returns the expression for a specific sort order. 265 * 266 * @param QueryBuilder $queryBuilder 267 * @param $sorter 268 * 269 * @throws \Exception 270 * 271 * @return \Doctrine\ORM\Query\Expr\Comparison|\Doctrine\ORM\Query\Expr\Func 272 */ 273 private function applyOrderByExpression(QueryBuilder $queryBuilder, Sorter $sorter) 274 { 275 if ($sorter->getAssociation() !== null) { 276 $alias = $this->getAlias('o.'.$sorter->getAssociation()).'.'.$sorter->getProperty(); 277 } else { 278 $alias = 'o.'.$sorter->getProperty(); 279 } 280 281 return $queryBuilder->addOrderBy($alias, $sorter->getDirection()); 282 } 283 284 /** 285 * {@inheritdoc} 286 */ 287 public function extractConfiguration($filterData, $sorterData) 288 { 289 $filters = []; 290 291 if (is_array($filterData)) { 292 foreach ($filterData as $filter) { 293 $filters[] = $this->extractJSONFilters($filter); 294 } 295 } elseif (is_object($filterData)) { 296 $filters[] = $this->extractJSONFilters($filterData); 297 } 298 299 $sorters = []; 300 301 if (is_array($sorterData)) { 302 foreach ($sorterData as $sorter) { 303 $sorters[] = $this->extractJSONSorters($sorter); 304 } 305 } elseif (is_object($sorterData)) { 306 $sorters[] = $this->extractJSONSorters($sorterData); 307 } 308 309 return ['filters' => $filters, 'sorters' => $sorters]; 310 } 311 312 /** 313 * Returns an alias for the given association property. 314 * 315 * @param string $property The property in FQDN format, e.g. "comments.authors.name" 316 * 317 * @return string The table alias 318 */ 319 private function getAlias( 320 $property 321 ) { 322 if (!array_key_exists($property, $this->aliases)) { 323 $this->aliases[$property] = 't'.count($this->aliases); 324 } 325 326 return $this->aliases[$property]; 327 } 328 329 /** 330 * Extracts the filters from the JSON object. 331 * 332 * @param $data 333 * 334 * @throws \Exception 335 * 336 * @return Filter 337 */ 338 private function extractJSONFilters( 339 $data 340 ) { 341 $filter = new Filter(); 342 343 if (property_exists($data, 'property')) { 344 if (strpos($data->property, '.') !== false) { 345 $associations = explode('.', $data->property); 346 347 $property = array_pop($associations); 348 349 $filter->setAssociation(implode('.', $associations)); 350 $filter->setProperty($property); 351 } else { 352 $filter->setAssociation(null); 353 $filter->setProperty($data->property); 354 } 355 } elseif (property_exists($data, "subfilters")) { 356 if (property_exists($data, 'type')) { 357 $filter->setType(strtolower($data->type)); 358 } 359 360 if (is_array($data->subfilters)) { 361 $subfilters = []; 362 foreach ($data->subfilters as $subfilter) { 363 $subfilters[] = $this->extractJSONFilters($subfilter); 364 } 365 $filter->setSubFilters($subfilters); 366 367 return $filter; 368 } else { 369 throw new \Exception("The subfilters must be an array of objects"); 370 } 371 } else { 372 throw new \Exception('You need to set the filter property'); 373 } 374 375 if (property_exists($data, 'operator')) { 376 $filter->setOperator($data->operator); 377 } else { 378 $filter->setOperator(Filter::OPERATOR_EQUALS); 379 } 380 381 if (property_exists($data, 'value')) { 382 $filter->setValue($this->getFilterValueFromUrl($data->value)); 383 } else { 384 throw new \Exception('No value specified'); 385 } 386 387 return $filter; 388 } 389 390 /** 391 * Extracts the sorters from the JSON object. 392 * 393 * @param $data 394 * 395 * @throws \Exception 396 * 397 * @return Sorter A Sorter object 398 */ 399 private function extractJSONSorters( 400 $data 401 ) { 402 $sorter = new Sorter(); 403 404 if ($data->property) { 405 if (strpos($data->property, '.') !== false) { 406 $associations = explode('.', $data->property); 407 408 $property = array_pop($associations); 409 410 $sorter->setAssociation(implode('.', $associations)); 411 $sorter->setProperty($property); 412 } else { 413 $sorter->setAssociation(null); 414 $sorter->setProperty($data->property); 415 } 416 } else { 417 throw new \Exception('You need to set the filter property'); 418 } 419 420 if ($data->direction) { 421 switch (strtoupper($data->direction)) { 422 case 'DESC': 423 $sorter->setDirection("DESC"); 424 break; 425 case 'ASC': 426 default: 427 $sorter->setDirection("ASC"); 428 break; 429 } 430 } else { 431 $sorter->setDirection("ASC"); 432 } 433 434 return $sorter; 435 } 436 }