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

Add OrSearchFilter - filter on multiple conditions using OR #398

Closed
WybrenKoelmans opened this issue Feb 1, 2016 · 32 comments
Closed

Add OrSearchFilter - filter on multiple conditions using OR #398

WybrenKoelmans opened this issue Feb 1, 2016 · 32 comments

Comments

@WybrenKoelmans
Copy link
Contributor

https://gist.github.com/WybrenKoelmans/81179b07ae0a10de30ba

I unfortunately do not have the time right now to make a proper PR, but using the gist above it should be straight forward what I intend.

The most important lines are
https://gist.github.com/WybrenKoelmans/81179b07ae0a10de30ba#file-orsearchfilter-php-L153

and the switch changes around
https://gist.github.com/WybrenKoelmans/81179b07ae0a10de30ba#file-orsearchfilter-php-L171

  resource.object.search_filter:
      parent: 'myapi.doctrine.orm.or_search_filter'
      arguments:
        - 'entity.user.name_first': 'partial'
          'entity.user.name_last':  'partial'
          'entity.user.telephone':  'partial'

Should give me something like

AND (entity.user.name_first LIKE '%testing%' OR
         entity.user.name_last LIKE '%testing%' OR
         entity.user.telephone LIKE '%testing%')
@theofidry
Copy link
Contributor

IMO if we add support for the or operator we should add support for operators or, and, gt(e), lt(e) and such.

@dunglas if we take this path it would be worth taking some work from LoopBackApiBundle. But this will require some work to be fair. Would you want to do it for v2 or v1 too?

@dunglas
Copy link
Member

dunglas commented Feb 15, 2016

@theofidry ok for me to include LoopBackApiBundle in core. Will you target the v1.1 or the v2?

@WybrenKoelmans
Copy link
Contributor Author

Excellent news, this looks promising. Thank you both.

@theofidry
Copy link
Contributor

@dunglas not sure I'll look more deeply this weekend. Basically I have two solutions:

  1. Finish the support of search on multi-values for the filters and then just merge it to DunglasApiBundle as a brand new filter
  2. Change the existing filter to ship with the new features

The 2. is very unlikely without any BC. I need to check it a bit more to be able to tell the easiest path. But it requires a bit of work to finish it, and much more time to ease the integration and check the performance impact.

@dunglas
Copy link
Member

dunglas commented Feb 15, 2016

BC means 2.0 (IMO it would be better to have it in 2.0 as 1.1 is very stable now).

@theofidry
Copy link
Contributor

Yep, 1. possibly means merge-able in v1, 2. guaranteed to go be in v2.

I'm gonna take a proper look at that this weekend (too busy this week~)

@soyuka
Copy link
Member

soyuka commented Feb 16, 2016

@theofidry IMO having a case insensitive filter (#384) would concern the same topic, filters. The #384 proposal might not be adequate in the way it's configurated (ipartial instead of partial + a boolean). Let me know if I can help.

@theofidry
Copy link
Contributor

Good reminder, will look into that as well :)

@Simperfit
Copy link
Contributor

@theofidry what is missing on this ?

@mcanepa
Copy link

mcanepa commented Feb 22, 2017

I'm in the need for this feature in my API; I have to search for a given value in several fields at the same time, so an OR filter will be very much welcome.

Is there any progress on this? some estimated release date?

@teohhanhui
Copy link
Contributor

@mcanepa:

I have to search for a given value in several fields at the same time

Best use a full-text search implementation then. If you're using PostgreSQL, the built-in Full Text Search is a good option.

There are certainly use cases for an OrFilter, I just don't think what you've described is one of them.

@soyuka
Copy link
Member

soyuka commented Feb 23, 2017

I agree about the Full Text Search, I've myself implemented a Filter with Oracle Text.

Anyway, you should try to build your own filter. There is a nice example here. You can also check the documentation about filters here and here.

@Simperfit
Copy link
Contributor

Are we going to implement it on core @api-platform/core-team

@soyuka
Copy link
Member

soyuka commented Jun 14, 2017

IMO it's not really worth the work (because this is never only a matter of AND or OR). We should advise to build custom filters if someone needs this.

@Simperfit
Copy link
Contributor

i'm -1 on this too, we could help people building their custom one, but that will not be in core :)

@hbroer
Copy link

hbroer commented Jul 22, 2017

Ty for helping on this. Now i know how to use this simple basic functionality with 6 Lines of Configuration. Not. Now I am building a own filter. That regex example helps very well. Not. ^^ just saying ^^

@hbroer
Copy link

hbroer commented Jul 22, 2017

<?php

namespace AppBundle\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;

use Doctrine\Common\Annotations\AnnotationReader;

final class SearchFilter extends AbstractFilter
{
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        if ($property === 'search') {
            $this->logger->info('Search for: ' . $value);
        } else {
            return;
        }

        $reader = new AnnotationReader();
        $annotation = $reader->getClassAnnotation(new \ReflectionClass(new $resourceClass), 'AppBundle\\Filter\\SearchAnnotation');

        if (!$annotation) {
            throw new \HttpInvalidParamException('No Search implemented.');
        }

        $parameterName = $queryNameGenerator->generateParameterName($property);
        $search = [];

        foreach ($annotation->fields as $field)
        {
            $search[] = "o.{$field} LIKE :{$parameterName}";
        }

        $queryBuilder->andWhere(implode(' OR ', $search));
        $queryBuilder->setParameter($parameterName, '%' . $value . '%');
    }

    /**
     * @param string $resourceClass
     * @return array
     */
    public function getDescription(string $resourceClass): array
    {
        $description['search'] = [
            'property' => 'search',
            'type' => 'string',
            'required' => false,
            'swagger' => ['description' => 'Searchfilter'],
        ];

        return $description;
    }
}
<?php

namespace AppBundle\Filter;

use Doctrine\Common\Annotations\Annotation;
use Doctrine\Common\Annotations\Annotation\Target;
use Doctrine\Common\Annotations\AnnotationException;

/**
 * @Annotation
 * @Target("CLASS")
 */
final class SearchAnnotation
{
    public $fields = [];

    /**
     * Constructor.
     *
     * @param array $data Key-value for properties to be defined in this class.
     * @throws AnnotationException
     */
    public function __construct(array $data)
    {
        if (!isset($data['value']) || !is_array($data['value'])) {
            throw new AnnotationException('Options must be a array of strings.');
        }

        foreach ($data['value'] as $key => $value) {
            if (is_string($value)) {
                $this->fields[] = $value;
            } else {
                throw new AnnotationException('Options must be a array of strings.');
            }
        }
    }
}

services.yml

    'AppBundle\Filter\SearchFilter':
        class: 'AppBundle\Filter\SearchFilter'
        autowire: true
        tags: [ { name: 'api_platform.filter', id: 'search' } ]

src/AppBundle/Entity/Product.php

<?php

namespace AppBundle\Entity;
[...]
use AppBundle\Filter\SearchAnnotation as Searchable;

/**
 * [...]
 * @Searchable({"name", "description", "whatever"})
 */
class Product { [...] }

@benjaminrau
Copy link

benjaminrau commented Nov 30, 2017

Great work @hbroer, that helped me a lot. I changed few things for my usecase:

  • annotation now supported properties on relations
  • description in swagger documentation contains info which properties are searched
  • search is now case-insensitive

src/AppBundle/Filter/SearchFilter.php

<?php

namespace AppBundle\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;

use Doctrine\Common\Annotations\AnnotationReader;

final class SearchFilter extends AbstractFilter
{
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        if ($property === 'search') {
            $this->logger->info('Search for: ' . $value);
        } else {
            return;
        }

        $reader = new AnnotationReader();
        $annotation = $reader->getClassAnnotation(new \ReflectionClass(new $resourceClass), \AppBundle\Filter\SearchAnnotation::class);

        if (!$annotation) {
            throw new \HttpInvalidParamException('No Search implemented.');
        }

        $parameterName = $queryNameGenerator->generateParameterName($property);
        $search = [];
        $mappedJoins = [];

        foreach ($annotation->fields as $field)
        {
            $joins = explode(".", $field);
            for ($lastAlias = 'o', $i = 0, $num = count($joins); $i < $num; $i++) {
                $currentAlias = $joins[$i];
                if ($i === $num - 1) {
                    $search[] = "LOWER({$lastAlias}.{$currentAlias}) LIKE LOWER(:{$parameterName})";
                } else {
                    $join = "{$lastAlias}.{$currentAlias}";
                    if (false === array_search($join, $mappedJoins)) {
                        $queryBuilder->leftJoin($join, $currentAlias);
                        $mappedJoins[] = $join;
                    }
                }

                $lastAlias = $currentAlias;
            }
        }

        $queryBuilder->andWhere(implode(' OR ', $search));
        $queryBuilder->setParameter($parameterName, '%' . $value . '%');
    }

    /**
     * @param string $resourceClass
     * @return array
     */
    public function getDescription(string $resourceClass): array
    {
        $reader = new AnnotationReader();
        $annotation = $reader->getClassAnnotation(new \ReflectionClass(new $resourceClass), \AppBundle\Filter\SearchAnnotation::class);

        $description['search'] = [
            'property' => 'search',
            'type' => 'string',
            'required' => false,
            'swagger' => ['description' => 'FullTextFilter on ' . implode(', ', $annotation->fields)],
        ];

        return $description;
    }
}

@iCodr8
Copy link
Contributor

iCodr8 commented Jan 31, 2018

Thank you so much @hbroer. This is exactly what I was looking for. At first I didn't got it working, because the filter definition in the @ApiResource annotation was missing.

So I have added this and it works:

/**
 * @ApiResource(
 *     attributes={
 *         "filters"={"search"}
 *     }
 * )
 * @Searchable({"name", "description", "whatever"})
 */
class Product { [...] }

And I optimized the search. So you can enter ones evi when you are searching Kevin Jones
over multiple fields.

final class SearchFilter extends AbstractFilter
{
    // ... other code

    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        if ($property === 'search') {
            $this->logger->info('Search for: ' . $value);
        } else {
            return;
        }

        $reader = new AnnotationReader();
        $annotation = $reader->getClassAnnotation(new \ReflectionClass(new $resourceClass), 'AppBundle\\Filter\\SearchAnnotation');

        if (!$annotation) {
            throw new \HttpInvalidParamException('No Search implemented.');
        }

        $parameterName = $queryNameGenerator->generateParameterName($property);
        $searchItems = explode(' ', str_replace('-', ' ', $value));

        if (is_array($searchItems)) {
            $andx = $queryBuilder->expr()->andx();

            foreach ($searchItems as $index => $searchItem) {
                $orx = $queryBuilder->expr()->orx();

                foreach ($annotation->fields as $field) {
                    $orx->add($queryBuilder->expr()->like('o.' . $field, ':' . $parameterName . '_' . $index));
                }

                if ($orx->count()) {
                    $queryBuilder->setParameter($parameterName . '_' . $index, '%' . $searchItem . '%');
                    $andx->add($orx);
                }
            }

            if ($andx->count()) {
                $queryBuilder->andWhere($andx);
            }
        }
    }

    // ... other code
}

@apfz
Copy link

apfz commented Mar 19, 2018

I am doing the above but the filterProperty method is never reached. Could anyone have a look at my detailed question regarding this at: https://stackoverflow.com/questions/49372971/custom-filter-api-platform-not-working

@zakjakub
Copy link

I have same problem as @apfz - filter appears in documentation, but it's never applied. filterProperty() is never reached.

@apfz
Copy link

apfz commented Aug 14, 2018

unfortunately I did not find a solution for it, so I had to switch to another API framework :(. glad I did though, filtering is now a breeze.

@teohhanhui
Copy link
Contributor

@zakjakub @apfz Please open another issue for your question. 😄

@renta
Copy link

renta commented Aug 21, 2018

Thanks @hbroer @benjaminrau for your code. I've done a custom OR filter without an annotation https://gist.github.com/renta/b6ece3fec7896440fe52a9ec0e76571a

@soyuka
Copy link
Member

soyuka commented Aug 21, 2018

looks good! Be careful with LOWER and performances though :)

@dardinier
Copy link

Hi @benjaminrau and @iCodr8, I liked both of your ways to do it.

But I need to combine those two features :

  • Search on properties :

annotation now supported properties on relations
From @benjaminrau

  • Search on multiples fields :

And I optimized the search. So you can enter ones evi when you are searching Kevin Jones
over multiple fields.
From @iCodr8

You both chose different ways to do it and today, I am not able to merge it.

How would you imagine it ?

@teohhanhui
Copy link
Contributor

@vipulw2011
Copy link

<?php

namespace AppBundle\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;

use Doctrine\Common\Annotations\AnnotationReader;

final class SearchFilter extends AbstractFilter
{
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        if ($property === 'search') {
            $this->logger->info('Search for: ' . $value);
        } else {
            return;
        }

        $reader = new AnnotationReader();
        $annotation = $reader->getClassAnnotation(new \ReflectionClass(new $resourceClass), 'AppBundle\\Filter\\SearchAnnotation');

        if (!$annotation) {
            throw new \HttpInvalidParamException('No Search implemented.');
        }

        $parameterName = $queryNameGenerator->generateParameterName($property);
        $search = [];

        foreach ($annotation->fields as $field)
        {
            $search[] = "o.{$field} LIKE :{$parameterName}";
        }

        $queryBuilder->andWhere(implode(' OR ', $search));
        $queryBuilder->setParameter($parameterName, '%' . $value . '%');
    }

    /**
     * @param string $resourceClass
     * @return array
     */
    public function getDescription(string $resourceClass): array
    {
        $description['search'] = [
            'property' => 'search',
            'type' => 'string',
            'required' => false,
            'swagger' => ['description' => 'Searchfilter'],
        ];

        return $description;
    }
}
<?php

namespace AppBundle\Filter;

use Doctrine\Common\Annotations\Annotation;
use Doctrine\Common\Annotations\Annotation\Target;
use Doctrine\Common\Annotations\AnnotationException;

/**
 * @Annotation
 * @Target("CLASS")
 */
final class SearchAnnotation
{
    public $fields = [];

    /**
     * Constructor.
     *
     * @param array $data Key-value for properties to be defined in this class.
     * @throws AnnotationException
     */
    public function __construct(array $data)
    {
        if (!isset($data['value']) || !is_array($data['value'])) {
            throw new AnnotationException('Options must be a array of strings.');
        }

        foreach ($data['value'] as $key => $value) {
            if (is_string($value)) {
                $this->fields[] = $value;
            } else {
                throw new AnnotationException('Options must be a array of strings.');
            }
        }
    }
}

services.yml

    'AppBundle\Filter\SearchFilter':
        class: 'AppBundle\Filter\SearchFilter'
        autowire: true
        tags: [ { name: 'api_platform.filter', id: 'search' } ]

src/AppBundle/Entity/Product.php

<?php

namespace AppBundle\Entity;
[...]
use AppBundle\Filter\SearchAnnotation as Searchable;

/**
 * [...]
 * @Searchable({"name", "description", "whatever"})
 */
class Product { [...] }

@hbroer I am beginner to api platform and I want to achieve search in multiple properties i.e OR Filter. Can you please help me configuring it.
@benjaminrau @iCodr8 If you can also, Please.
Thanks in advance

@teohhanhui
Copy link
Contributor

@vipulw2011 Please use the proper channels for support questions. Thanks!

You can ask on the #api-platform channel of Symfony Slack.

@kacperjurak
Copy link

Using of ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter is deprecated.

Please use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter instead.

@masseelch
Copy link

masseelch commented Jul 11, 2020

@dardinier I think this is what you are looking for: https://gist.github.com/masseelch/47931f3a745409f8f44c69efa9ecb05c

@hbroer, @benjaminrau

@khaledBou
Copy link

I tried to add the custom search filter to an abstract class but it doesn't work , i can't instantiate an abstract class, is any solution to prevent the new $resourceClass ? or maybe a custom search filter for an abstract class ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests