Skip to content

Commit

Permalink
Add StringList filter to search value in @ORM\Column(type="array") (#…
Browse files Browse the repository at this point in the history
…1042)

* Use FQCN class in doc

* Add StringListFilter
  • Loading branch information
VincentLanglet authored May 23, 2020
1 parent f5506b1 commit 90e2bbe
Show file tree
Hide file tree
Showing 3 changed files with 263 additions and 18 deletions.
64 changes: 46 additions & 18 deletions docs/reference/filter_field_definition.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ Available filter types

For now, only `Doctrine ORM` filters are available:

* ``Sonata\DoctrineORMAdminBundle\Filter\BooleanFilter``: depends on the ``sonata_type_filter_default`` Form Type, renders yes or no field,
* ``Sonata\DoctrineORMAdminBundle\Filter\CallbackFilter``: depends on the ``sonata_type_filter_default`` Form Type, types can be configured as needed,
* ``Sonata\DoctrineORMAdminBundle\Filter\ChoiceFilter``: depends on the ``sonata_type_filter_choice`` Form Type, renders yes or no field,
* ``Sonata\DoctrineORMAdminBundle\Filter\NumberFilter``: depends on the ``sonata_type_filter_number`` Form Type,
* ``Sonata\DoctrineORMAdminBundle\Filter\ModelAutocompleteFilter``: uses ``sonata_type_model_autocomplete`` form type, can be used as replacement of ``Sonata\DoctrineORMAdminBundle\Filter\ModelFilter`` to handle too many items that cannot be loaded into memory.
* ``Sonata\DoctrineORMAdminBundle\Filter\StringFilter``: depends on the ``sonata_type_filter_choice``,
* ``Sonata\DoctrineORMAdminBundle\Filter\NumberFilter``: depends on the ``sonata_type_filter_choice`` Form Type, renders yes or no field,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateFilter``: depends on the ``sonata_type_filter_date`` Form Type, renders a date field,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateRangeFilter``: depends on the ``sonata_type_filter_date_range`` Form Type, renders a 2 date fields,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateTimeFilter``: depends on the ``sonata_type_filter_datetime`` Form Type, renders a datetime field,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateTimeRangeFilter``: depends on the ``sonata_type_filter_datetime_range`` Form Type, renders a 2 datetime fields,
* ``Sonata\DoctrineORMAdminBundle\Filter\ClassFilter``: depends on the ``sonata_type_filter_default`` Form type, renders a choice list field.
* ``Sonata\DoctrineORMAdminBundle\Filter\BooleanFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DefaultType`` Form Type, renders yes or no field,
* ``Sonata\DoctrineORMAdminBundle\Filter\CallbackFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DefaultType`` Form Type, types can be configured as needed,
* ``Sonata\DoctrineORMAdminBundle\Filter\ChoiceFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\ChoiceType`` Form Type,
* ``Sonata\DoctrineORMAdminBundle\Filter\NumberFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\NumberType`` Form Type,
* ``Sonata\DoctrineORMAdminBundle\Filter\ModelAutocompleteFilter``: uses ``Sonata\AdminBundle\Form\Type\Filter\ModelAutocompleteType`` form type, can be used as replacement of ``Sonata\DoctrineORMAdminBundle\Filter\ModelFilter`` to handle too many items that cannot be loaded into memory.
* ``Sonata\DoctrineORMAdminBundle\Filter\StringFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\ChoiceType`` Form Type,
* ``Sonata\DoctrineORMAdminBundle\Filter\StringListFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\ChoiceType`` Form Type,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DateType`` Form Type, renders a date field,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateRangeFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DateRangeType`` Form Type, renders a 2 date fields,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateTimeFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DateTimeType`` Form Type, renders a datetime field,
* ``Sonata\DoctrineORMAdminBundle\Filter\DateTimeRangeFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DateTimeRangeType`` Form Type, renders a 2 datetime fields,
* ``Sonata\DoctrineORMAdminBundle\Filter\ClassFilter``: depends on the ``Sonata\AdminBundle\Form\Type\Filter\DefaultType`` Form type, renders a choice list field.

Example
-------
Expand All @@ -66,8 +66,36 @@ Example
}
}
doctrine_orm_model_autocomplete
-------------------------------
StringListFilter
----------------

This filter is made for filtering on values saved in databases as serialized arrays of strings with the
``@ORM\Column(type="array")`` annotation. It is recommended to use another table and ``OneToMany`` relations
if you want to make complex ``SQL`` queries or if your table is too big and you get performance issues but
this filter can provide some basic queries::

protected function configureDatagridFilters(DatagridMapper $datagridMapper): void
{
$datagridMapper
->add('labels', StringListFilter::class, [], ChoiceType::class, [
'choices' => [
'patch' => 'patch',
'minor' => 'minor',
'major' => 'major',
'approved' => 'approved',
// ...
],
'multiple' => true,
]);
}

.. note::

The filter can give bad results with associative arrays since it is not easy to distinguish between keys
and values for a serialized associative array.

ModelAutocompleteFilter
-----------------------

This filter type uses ``Sonata\AdminBundle\Form\Type\ModelAutocompleteType`` form type. It renders an input with select2 autocomplete feature.
Can be used as replacement of ``Sonata\DoctrineORMAdminBundle\Filter\ModelFilter`` to handle too many related items that cannot be loaded into memory.
Expand All @@ -82,8 +110,8 @@ This form type requires ``property`` option. See documentation of ``Sonata\Admin
]);
}

doctrine_orm_date_range
-----------------------
DateRangeFilter
---------------

The ``Sonata\DoctrineORMAdminBundle\Filter\DateRangeFilter`` filter renders two fields to filter all records between two dates.
If only one date is set it will filter for all records until or since the given date::
Expand Down Expand Up @@ -114,8 +142,8 @@ support filtering of timestamp fields by specifying ``'input_type' => 'timestamp
}
}

Class
-----
ClassFilter
-----------

``Sonata\DoctrineORMAdminBundle\Filter\ClassFilter`` supports filtering on hierarchical entities. You need to specify the ``sub_classes`` option::

Expand Down
62 changes: 62 additions & 0 deletions src/Filter/StringListFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Sonata Project package.
*
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Sonata\DoctrineORMAdminBundle\Filter;

use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Sonata\AdminBundle\Form\Type\Filter\ChoiceType;
use Sonata\AdminBundle\Form\Type\Operator\ContainsOperatorType;

final class StringListFilter extends Filter
{
public function filter(ProxyQueryInterface $queryBuilder, $alias, $field, $data): void
{
if (!$data || !\is_array($data) || !\array_key_exists('type', $data) || !\array_key_exists('value', $data)) {
return;
}

if (!\is_array($data['value'])) {
$data['value'] = [$data['value']];
}

$operator = ContainsOperatorType::TYPE_NOT_CONTAINS === $data['type'] ? 'NOT LIKE' : 'LIKE';

$andConditions = $queryBuilder->expr()->andX();
foreach ($data['value'] as $value) {
$parameterName = $this->getNewParameterName($queryBuilder);
$andConditions->add(sprintf('%s.%s %s :%s', $alias, $field, $operator, $parameterName));

$queryBuilder->setParameter($parameterName, '%'.serialize($value).'%');
}

if (ContainsOperatorType::TYPE_EQUAL === $data['type']) {
$andConditions->add(sprintf("%s.%s LIKE 'a:%s:%%'", $alias, $field, \count($data['value'])));
}

$this->applyWhere($queryBuilder, $andConditions);
}

public function getDefaultOptions(): array
{
return [];
}

public function getRenderSettings(): array
{
return [ChoiceType::class, [
'field_type' => $this->getFieldType(),
'field_options' => $this->getFieldOptions(),
'label' => $this->getLabel(),
]];
}
}
155 changes: 155 additions & 0 deletions tests/Filter/StringListFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Sonata Project package.
*
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Sonata\DoctrineORMAdminBundle\Tests\Filter;

use PHPUnit\Framework\TestCase;
use Sonata\AdminBundle\Form\Type\Operator\ContainsOperatorType;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery;
use Sonata\DoctrineORMAdminBundle\Filter\StringListFilter;

class StringListFilterTest extends TestCase
{
public function testItStaysDisabledWhenFilteringWithAnEmptyValue(): void
{
$filter = new StringListFilter();
$filter->initialize('field_name', ['field_options' => ['class' => 'FooBar']]);

$builder = new ProxyQuery(new QueryBuilder());

$filter->filter($builder, 'alias', 'field', null);
$filter->filter($builder, 'alias', 'field', '');

$this->assertSame([], $builder->query);
$this->assertFalse($filter->isActive());
}

public function testFilteringWithNullReturnsArraysThatContainNull(): void
{
$filter = new StringListFilter();
$filter->initialize('field_name');

$builder = new ProxyQuery(new QueryBuilder());
$this->assertSame([], $builder->query);

$filter->filter($builder, 'alias', 'field', ['value' => null, 'type' => null]);
$this->assertSame(['alias.field LIKE :field_name_0'], $builder->query);
$this->assertSame(['field_name_0' => '%N;%'], $builder->parameters);
$this->assertTrue($filter->isActive());
}

/**
* @dataProvider containsDataProvider
*/
public function testContains(?int $type): void
{
$filter = new StringListFilter();
$filter->initialize('field_name');

$builder = new ProxyQuery(new QueryBuilder());
$this->assertSame([], $builder->query);

$filter->filter($builder, 'alias', 'field', ['value' => 'asd', 'type' => $type]);
$this->assertSame(['alias.field LIKE :field_name_0'], $builder->query);
$this->assertSame(['field_name_0' => '%s:3:"asd";%'], $builder->parameters);
$this->assertTrue($filter->isActive());
}

public function containsDataProvider(): iterable
{
yield 'explicit contains' => [ContainsOperatorType::TYPE_CONTAINS];
yield 'implicit contains' => [null];
}

public function testNotContains(): void
{
$filter = new StringListFilter();
$filter->initialize('field_name');

$builder = new ProxyQuery(new QueryBuilder());
$this->assertSame([], $builder->query);

$filter->filter($builder, 'alias', 'field', ['value' => 'asd', 'type' => ContainsOperatorType::TYPE_NOT_CONTAINS]);
$this->assertSame(['alias.field NOT LIKE :field_name_0'], $builder->query);
$this->assertSame(['field_name_0' => '%s:3:"asd";%'], $builder->parameters);
$this->assertTrue($filter->isActive());
}

public function testEquals(): void
{
$filter = new StringListFilter();
$filter->initialize('field_name');

$builder = new ProxyQuery(new QueryBuilder());
$this->assertSame([], $builder->query);

$filter->filter($builder, 'alias', 'field', ['value' => 'asd', 'type' => ContainsOperatorType::TYPE_EQUAL]);
$this->assertSame(['alias.field LIKE :field_name_0 AND alias.field LIKE \'a:1:%\''], $builder->query);
$this->assertSame(['field_name_0' => '%s:3:"asd";%'], $builder->parameters);
$this->assertTrue($filter->isActive());
}

/**
* @param array<string> $value
* @param array<string> $query
* @param array<string, string> $parameters
*
* @dataProvider multipleValuesDataProvider
*/
public function testMultipleValues(array $value, ?int $type, array $query, array $parameters): void
{
$filter = new StringListFilter();
$filter->initialize('field_name');

$builder = new ProxyQuery(new QueryBuilder());
$this->assertSame([], $builder->query);

$filter->filter($builder, 'alias', 'field', ['value' => $value, 'type' => $type]);
$this->assertSame($query, $builder->query);
$this->assertSame($parameters, $builder->parameters);
$this->assertTrue($filter->isActive());
}

public function multipleValuesDataProvider(): iterable
{
yield 'equal choice' => [
['asd', 'qwe'],
ContainsOperatorType::TYPE_EQUAL,
["alias.field LIKE :field_name_0 AND alias.field LIKE :field_name_1 AND alias.field LIKE 'a:2:%'"],
[
'field_name_0' => '%s:3:"asd";%',
'field_name_1' => '%s:3:"qwe";%',
],
];

yield 'contains choice' => [
['asd', 'qwe'],
ContainsOperatorType::TYPE_CONTAINS,
['alias.field LIKE :field_name_0 AND alias.field LIKE :field_name_1'],
[
'field_name_0' => '%s:3:"asd";%',
'field_name_1' => '%s:3:"qwe";%',
],
];

yield 'not contains choice' => [
['asd', 'qwe'],
ContainsOperatorType::TYPE_NOT_CONTAINS,
['alias.field NOT LIKE :field_name_0 AND alias.field NOT LIKE :field_name_1'],
[
'field_name_0' => '%s:3:"asd";%',
'field_name_1' => '%s:3:"qwe";%',
],
];
}
}

0 comments on commit 90e2bbe

Please sign in to comment.