Skip to content

Commit

Permalink
feat(doctrine): search filters like laravel eloquent filters
Browse files Browse the repository at this point in the history
  • Loading branch information
vinceAmstoutz committed Dec 24, 2024
1 parent 716a43b commit e3b56c8
Show file tree
Hide file tree
Showing 19 changed files with 1,237 additions and 0 deletions.
54 changes: 54 additions & 0 deletions src/Doctrine/Orm/Filter/ExactSearchFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\ExactSearchFilterTrait;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

final class ExactSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
{
use ExactSearchFilterTrait;
use FilterInterfaceTrait;

public function __construct(
private ?ManagerRegistry $managerRegistry = null,
private readonly ?array $properties = null,
private readonly ?NameConverterInterface $nameConverter = null,
) {
}

protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if (
null === $value
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}

$alias = $queryBuilder->getRootAliases()[0];
$parameterName = $queryNameGenerator->generateParameterName($property);

$queryBuilder
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
->setParameter($parameterName, $value);
}
}
78 changes: 78 additions & 0 deletions src/Doctrine/Orm/Filter/FilterInterfaceTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

trait FilterInterfaceTrait
{
use OrmPropertyHelperTrait;
use PropertyHelperTrait;

public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
foreach ($context['filters'] as $property => $value) {
$this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
}

public function getDescription(string $resourceClass): array
{
throw new RuntimeException('Not implemented.');
}

/**
* Determines whether the given property is enabled.
*/
protected function isPropertyEnabled(string $property, string $resourceClass): bool
{
if (null === $this->properties) {
// to ensure sanity, nested properties must still be explicitly enabled
return !$this->isPropertyNested($property, $resourceClass);
}

return \array_key_exists($property, $this->properties);
}

protected function denormalizePropertyName(string|int $property): string
{
if (!$this->nameConverter instanceof NameConverterInterface) {
return (string) $property;
}

return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
}

public function hasManagerRegistry(): bool
{
return $this->managerRegistry instanceof ManagerRegistry;
}

public function getManagerRegistry(): ManagerRegistry
{
return $this->managerRegistry;
}

public function setManagerRegistry(ManagerRegistry $managerRegistry): void
{
$this->managerRegistry = $managerRegistry;
}
}
89 changes: 89 additions & 0 deletions src/Doctrine/Orm/Filter/IriSearchFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
use ApiPlatform\Metadata\PropertiesAwareInterface;
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
use ApiPlatform\State\Provider\IriConverterParameterProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

final class IriSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface, PropertiesAwareInterface, ParameterProviderFilterInterface
{
use FilterInterfaceTrait;

public function __construct(
private ?ManagerRegistry $managerRegistry = null,
private readonly ?array $properties = null,
private readonly ?NameConverterInterface $nameConverter = null,
) {
}

protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if (
null === $value
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}

$value = $context['parameter']->getValue();

$alias = $queryBuilder->getRootAliases()[0];
$parameterName = $queryNameGenerator->generateParameterName($property);

$queryBuilder
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
->setParameter($parameterName, $value);
}

/**
* {@inheritdoc}
*/
public function getType(string $doctrineType): string
{
// TODO: remove this test when doctrine/dbal:3 support is removed
if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
return 'array';
}

return match ($doctrineType) {
Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
Types::BOOLEAN => 'bool',
Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class,
Types::FLOAT => 'float',
default => 'string',
};
}

public static function getParameterProvider(): string
{
return IriConverterParameterProvider::class;
}

public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
{
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
}
}
59 changes: 59 additions & 0 deletions src/Doctrine/Orm/Filter/PartialSearchFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

final class PartialSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
{
use FilterInterfaceTrait;

public function __construct(
private ?ManagerRegistry $managerRegistry = null,
private readonly ?array $properties = null,
private readonly ?NameConverterInterface $nameConverter = null,
) {
}

protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if (
null === $value
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}

$alias = $queryBuilder->getRootAliases()[0];
$parameterName = $queryNameGenerator->generateParameterName($property);

$queryBuilder
->andWhere(\sprintf('%s.%s LIKE :%s', $alias, $property, $parameterName))
->setParameter($parameterName, '%'.$value.'%');
}

public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
{
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
}
}
7 changes: 7 additions & 0 deletions src/Metadata/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ public function getValue(mixed $default = new ParameterNotFound()): mixed
return $this->extraProperties['_api_values'] ?? $default;
}

public function setValue(mixed $value): static
{
$this->extraProperties['_api_values'] = $value;

return $this;
}

/**
* @return array<string, mixed>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource
if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) {
$parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider');
}

$currentKey = $key;
if (null === $parameter->getProperty() && isset($properties[$key])) {
$parameter = $parameter->withProperty($key);
Expand Down
65 changes: 65 additions & 0 deletions src/State/Provider/IriConverterParameterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\State\Provider;

use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\IdentifiersExtractor;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\State\ParameterProviderInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

final readonly class IriConverterParameterProvider implements ParameterProviderInterface
{
public function __construct(
private IriConverterInterface $iriConverter,
private PropertyAccessorInterface $propertyAccessor,
private ?IdentifiersExtractor $identifiersExtractor = null,
) {
}

public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
{
$operation = $context['operation'] ?? null;
$value = $parameter->getValue();
if (!$value) {
return $operation;
}

$id = $this->getIdFromValue($value);
$parameter->setValue($id);

return $operation;
}

protected function getIdFromValue(string $value): mixed
{
try {
$item = $this->iriConverter->getResourceFromIri($value, ['fetch_data' => false]);

if (null === $this->identifiersExtractor) {
return $this->propertyAccessor->getValue($item, 'id');
}

$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item);

return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers;
} catch (InvalidArgumentException) {
// Do nothing, return the raw value
}

return $value;
}
}
21 changes: 21 additions & 0 deletions src/Symfony/Bundle/Resources/config/doctrine_orm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,27 @@
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.iri_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\IriSearchFilter" public="false">
<argument type="service" id="doctrine"/>
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>

<tag name="api_platform.filter" priority="-100"/>
</service>

<service id="api_platform.doctrine.orm.exact_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\ExactSearchFilter" public="false">
<argument type="service" id="doctrine"/>
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>

<tag name="api_platform.filter" priority="-100"/>
</service>

<service id="api_platform.doctrine.orm.partial_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter" public="false">
<argument type="service" id="doctrine"/>
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>

<tag name="api_platform.filter" priority="-100"/>
</service>

<service id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory" class="ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="40">
<argument type="service" id="doctrine" />
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />
Expand Down
Loading

0 comments on commit e3b56c8

Please sign in to comment.