Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(doctrine): search filters like laravel eloquent filters #6865

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/Doctrine/Orm/Filter/EndSearchFilter.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 EndSearchFilter 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should hardcode parameter value style in the filter, I would rather be able to define the default strategy to use in the configuration and fallback to the default

Suggested change
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
return new OpenApiParameter(name: $parameter->getKey(), in: 'query');

should be suffisant for most of the cases.

Any thoughts @soyuka ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you define your own OpenApiParameter this will not get called AFAIK

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's the final step (before supporting Odm) @soyuka @aegypius

}
}
59 changes: 59 additions & 0 deletions src/Doctrine/Orm/Filter/ExactSearchFilter.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 ExactSearchFilter 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;
}
Comment on lines +39 to +45
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: try to remove


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

$queryBuilder
->andWhere(\sprintf('%s.%s = :%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);
}
}
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;
Comment on lines +32 to +76
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: try to remove

}
}
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TO DO: remove

{
// 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',
vinceAmstoutz marked this conversation as resolved.
Show resolved Hide resolved
};
}

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);
}
}
Loading
Loading