Pull to refresh

LexikFormFilterBundle, создаем фильтрующие формы еще быстрее

Reading time 9 min
Views 5.7K
Сразу, коротко и по делу о том, какие типы фильтров вы сможете использовать из коробки с уже созданной кастомизацией:

BooleanFilterType
CheckboxFilterType
ChoiceFilterType
CollectionAdapterFilterType
DateFilterType
DateRangeFilterType
DateTimeFilterType
DateTimeRangeFilterType
DocumentFilterType
EmbeddedFilterTypeInterface
EntityFilterType
NumberFilterType
NumberRangeFilterType
SharedableFilterType
TextFilterType

Судя из названий должно быть примерно понятно, что конкретно делает тот или иной фильтр, при этом ничего не мешает создавать свои собственные фильтры или расширять существующие.

Я не буду здесь останавливаться на том, как создать таблицы из наших сущностей. Статья ориентирована на людей, которые владеют хотя бы основами работы с этим замечательным фреймворком.

ТЗ


Я рассмотрю пример, где ситуация с требованием к фильтру максимально усложнена. Перед нами будет стоять задача: создать форму поиска мероприятий на сайте с различными фильтрами и сделать это максимально гибко.

При этом фильтр должен отсеивать данные в базе по следующим критериям:

  1. Период времени, когда это мероприятие должно проходить.
  2. По статусу и категории мероприятия, которые являются связанными сущностями.
  3. По определенному кол-ву связей с другой сущностью — будем делать ползунок, который позволяет отфильтровать мероприятия по минимальному и максимальному кол-ву текущих участников, зарегистрированных в системе.
  4. По минимальной и максимальной цене.

Установка


Загружаем необходимые зависимости

composer require lexik/form-filter-bundle

Регистрируем бандл в приложении:

$bundles = array(
	    ...
            new Lexik\Bundle\FormFilterBundle\LexikFormFilterBundle(),

В нашем случае база данных MySQL, поэтому минимальная конфигурация для бандла выглядит следующим образом:

lexik_form_filter:
    listeners:
        doctrine_orm: true
        doctrine_dbal: false
        doctrine_mongodb: false

Вот и всё, теперь нам осталось потратить совсем немного времени на то, чтобы сделать нашу форму для фильтра.

Реализация


Для начала мы создадим наши сущности и базу данных:

Сущность мероприятия (Meet):

Код
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
 
/**
 * Meet
 *
 * @ORM\Table(name="meet")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\MeetRepository")
 */
class Meet
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\Column(type="string")
     */
    private $title;
 
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Category")
     */
    private $category;
 
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Status")
     */
    private $status;
 
    /**
     * @ORM\Column(type="decimal")
     */
    private $price = 0;
 
    /**
     * @ORM\Column(type="datetime")
     */
    private $startDate;
 
    /**
     * @ORM\Column(type="datetime")
     */
    private $endDate;
 
    /**
     * @ORM\OneToMany(targetEntity="AppBundle\Entity\Participant", mappedBy="meet")
     */
    private $participants;
 
    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->participants = new \Doctrine\Common\Collections\ArrayCollection();
    }
 
    /**
     * Set title
     *
     * @param string $title
     *
     * @return Meet
     */
    public function setTitle($title)
    {
        $this->title = $title;
 
        return $this;
    }
 
    /**
     * Get title
     *
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }
 
    /**
     * Set price
     *
     * @param string $price
     *
     * @return Meet
     */
    public function setPrice($price)
    {
        $this->price = $price;
 
        return $this;
    }
 
    /**
     * Get price
     *
     * @return string
     */
    public function getPrice()
    {
        return $this->price;
    }
 
    /**
     * Set startDate
     *
     * @param \DateTime $startDate
     *
     * @return Meet
     */
    public function setStartDate($startDate)
    {
        $this->startDate = $startDate;
 
        return $this;
    }
 
    /**
     * Get startDate
     *
     * @return \DateTime
     */
    public function getStartDate()
    {
        return $this->startDate;
    }
 
    /**
     * Set endDate
     *
     * @param \DateTime $endDate
     *
     * @return Meet
     */
    public function setEndDate($endDate)
    {
        $this->endDate = $endDate;
 
        return $this;
    }
 
    /**
     * Get endDate
     *
     * @return \DateTime
     */
    public function getEndDate()
    {
        return $this->endDate;
    }
 
    /**
     * Set category
     *
     * @param \AppBundle\Entity\Category $category
     *
     * @return Meet
     */
    public function setCategory(\AppBundle\Entity\Category $category = null)
    {
        $this->category = $category;
 
        return $this;
    }
 
    /**
     * Get category
     *
     * @return \AppBundle\Entity\Category
     */
    public function getCategory()
    {
        return $this->category;
    }
 
    /**
     * Set status
     *
     * @param \AppBundle\Entity\Status $status
     *
     * @return Meet
     */
    public function setStatus(\AppBundle\Entity\Status $status = null)
    {
        $this->status = $status;
 
        return $this;
    }
 
    /**
     * Get status
     *
     * @return \AppBundle\Entity\Status
     */
    public function getStatus()
    {
        return $this->status;
    }
 
    /**
     * Add participant
     *
     * @param \AppBundle\Entity\Participant $participant
     *
     * @return Meet
     */
    public function addParticipant(\AppBundle\Entity\Participant $participant)
    {
        $this->participants[] = $participant;
 
        return $this;
    }
 
    /**
     * Remove participant
     *
     * @param \AppBundle\Entity\Participant $participant
     */
    public function removeParticipant(\AppBundle\Entity\Participant $participant)
    {
        $this->participants->removeElement($participant);
    }
 
    /**
     * Get participants
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getParticipants()
    {
        return $this->participants;
    }
}


Сущность статуса мероприятия (Status):

Код
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
 
/**
 * Status
 *
 * @ORM\Table(name="status")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\StatusRepository")
 */
class Status
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\Column(type="string")
     */
    private $title;
 
 
    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }
 
    /**
     * Set title
     *
     * @param string $title
     *
     * @return Status
     */
    public function setTitle($title)
    {
        $this->title = $title;
 
        return $this;
    }
 
    /**
     * Get title
     *
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }
}


Сущность категории мероприятия (Category):

Код
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
 
/**
 * Category
 *
 * @ORM\Table(name="category")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\CategoryRepository")
 */
class Category
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\Column(type="string")
     */
    private $title;
 
 
    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }
 
    /**
     * Set title
     *
     * @param string $title
     *
     * @return Category
     */
    public function setTitle($title)
    {
        $this->title = $title;
 
        return $this;
    }
 
    /**
     * Get title
     *
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }
}


Промежуточная сущность, связывающая пользователя и мероприятия по Многие ко Многим (превращает пользователя в участника) (Participant):

Код
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
 
/**
 * Participant
 *
 * @ORM\Table(name="participant")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\ParticipantRepository")
 */
class Participant
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="participants")
     */
    private $user;
 
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Meet", inversedBy="participants")
     */
    private $meet;
 
 
    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }
 
    /**
     * Set user
     *
     * @param \AppBundle\Entity\User $user
     *
     * @return Participant
     */
    public function setUser(\AppBundle\Entity\User $user = null)
    {
        $this->user = $user;
 
        return $this;
    }
 
    /**
     * Get user
     *
     * @return \AppBundle\Entity\User
     */
    public function getUser()
    {
        return $this->user;
    }
 
    /**
     * Set meet
     *
     * @param \AppBundle\Entity\Meet $meet
     *
     * @return Participant
     */
    public function setMeet(\AppBundle\Entity\Meet $meet = null)
    {
        $this->meet = $meet;
 
        return $this;
    }
 
    /**
     * Get meet
     *
     * @return \AppBundle\Entity\Meet
     */
    public function getMeet()
    {
        return $this->meet;
    }
}

Сама сущность пользователя (User):

namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
 
/**
 * User
 *
 * @ORM\Table(name="user")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
 */
class User
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;
}


Отлично, мы создали структуру, с которой уже можно работать. Теперь можно приступать к созданию класса нашей формы:

namespace AppBundle\Filter;
 
use Lexik\Bundle\FormFilterBundle\Filter\Query\QueryInterface;
use AppBundle\Entity\Category;
use AppBundle\Entity\Status;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Lexik\Bundle\FormFilterBundle\Filter\Form\Type as Filters;
 
class MeetFilter extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->setMethod('GET');
        $builder
            ->add('category', Filters\EntityFilterType::class, [
                'data_class' => Category::class,
                'class' => Category::class
            ])
            ->add('status', Filters\EntityFilterType::class, [
                'data_class' => Status::class,
                'class' => Status::class
            ])
            ->add('startDate', Filters\DateTimeRangeFilterType::class)
            ->add('participant_count', Filters\NumberRangeFilterType::class, [
                'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
                    if (empty($values['value']['left_number'][0]) && empty($values['value']['right_number'][0])) {
                        return null;
                    }
 
                    $start = !empty($values['value']['left_number'][0]) ? $values['value']['left_number'][0] : null;
                    $end = !empty($values['value']['right_number'][0]) ? $values['value']['right_number'][0] : null;
 
                    $paramName = sprintf('p_%s', str_replace('.', '_', $field));
                    $filterQuery->getQueryBuilder()
                        ->leftJoin('meet.participants', 'pp')
                        ->addSelect(sprintf('COUNT(pp) AS %s', $paramName))
                        ->addGroupBy('meet.id')
                    ;
 
                    if($start && $end) {
                        $filterQuery->getQueryBuilder()
                                    ->having(sprintf('%s > %d AND %s < %d', $paramName, $start, $paramName, $end));
                    } elseif($start && !$end) {
                        $filterQuery->getQueryBuilder()
                            ->having(sprintf('%s > %d', $paramName, $start));
                    } elseif(!$start && $end) {
                        $filterQuery->getQueryBuilder()
                            ->having(sprintf('%s < %d', $paramName, $end));
                    }
                }
            ])
            ->add('price', Filters\NumberRangeFilterType::class);
    }
 
    public function getBlockPrefix()
    {
        return 'item_filter';
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'csrf_protection'   => false,
            'validation_groups' => array('filtering') // avoid NotBlank() constraint-related message
        ));
    }
}

Здесь самое интересное — это кэллбек apply_filter в опции к participantCount фильтру.

Данный фильтр задает минимальное и максимальное кол-во участников для выборки, данные о которых берет из формы. Далее мы меняем запрос, говоря Doctrine, что хотим чтобы в запросе теперь появились данные о кол-ве связей Meet с Participant:

$paramName = sprintf('p_%s', str_replace('.', '_', $field));
$filterQuery->getQueryBuilder()
	->leftJoin('meet.participants', 'pp')
	->addSelect(sprintf('COUNT(pp) AS %s', $paramName))
	->addGroupBy('meet.id')
    ;

И нам остается лишь задать следующие условия:

if($start && $end) {
	$filterQuery->getQueryBuilder()
		    ->having(sprintf('%s > %d AND %s < %d', $paramName, $start, $paramName, $end));
} elseif($start && !$end) {
	$filterQuery->getQueryBuilder()
	    ->having(sprintf('%s > %d', $paramName, $start));
} elseif(!$start && $end) {
	$filterQuery->getQueryBuilder()
	    ->having(sprintf('%s < %d', $paramName, $end));
}

Которые, относительно ситуации, должны выполнить:

  1. Если заданы оба параметра: максимальное и минимальное кол-во условий — используем оба.
  2. Если задано только минимальное кол-во участников — ищем мероприятия с большим кол-вом от указанного.
  3. Если задано только максимальное кол-во участников — ищем мероприятия с меньшим кол-вом от указанного.

Форма создается таким же незамысловатым образом, как и любые другие формы в Symfony. Из нового мы тут видим только лишь набор новых для нас FieldType классов, которые помогут нам решить практически любые задачи фильтрации данных, об этих типах и велась речь в начале статьи.

Теперь пишем наш контроллер:

public function indexAction(Request $request)
{
	$repository = $this->getDoctrine()
	    ->getRepository('AppBundle:Meet');

	$form = $this->get('form.factory')->create(MeetFilter::class);

	if ($request->query->has($form->getName())) {
	    $form->submit($request->query->get($form->getName()));

	    $filterBuilder = $repository->createQueryBuilder('meet');
	    $this->get('lexik_form_filter.query_builder_updater')->addFilterConditions($form, $filterBuilder);

	    $filterBuilder->join('meet.status', 's');
	    $query = $filterBuilder->getQuery();
	    $form = $this->get('form.factory')->create(MeetFilter::class);
	} else {
	    $query = $repository->createQueryBuilder('meet')
		->join('meet.status', 's')
		->getQuery();
	}
	$meets = $query->getResult();
}

Тут все просто: мы проверяем, что если какие-то данные с формы были отправлены, то запускаем addFilterConditions, сообщая ему данные формы и QueryBuilder с репозитория Meet.

Далее сервис бандла сам доставит необходимые условия в QueryBuilder, с которым мы уже будем работать.

В итоге стандартное представление нашей формы выглядит примерно следующим образом:

image

Я очень люблю, когда всё красиво, поэтому я просто оставлю это здесь:

image

Данный бандл всего лишь дополняет нам наш объект QueryBuilder-а, позволяя впоследствии нам решать, что с ним делать: передавать в пагинацию или дополнять какими-то своими кастомными вещами.

В итоге наш DQL будет выглядеть примерно следующим образом:

SELECT meet, COUNT(pp) AS p_meet_participant_count FROM AppBundle\Entity\Meet meet 
	LEFT JOIN meet.participants pp 
	INNER JOIN meet.status s 
WHERE meet.category = :p_meet_category 
	AND meet.status = :p_meet_status 
	AND (meet.startDate <= '2017-01-31 00:00:00' AND meet.startDate >= '2017-01-01 00:00:00') 
	AND (meet.price >= :p_meet_price_left AND meet.price <= :p_meet_price_right) 
GROUP BY meet.id HAVING p_meet_participant_count > 90 AND p_meet_participant_count < 256

Github
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+9
Comments 7
Comments Comments 7

Articles