11 января в 21:42

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

Сразу, коротко и по делу о том, какие типы фильтров вы сможете использовать из коробки с уже созданной кастомизацией:

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
Андреев Данил @php_freelancer
карма
6,0
рейтинг 0,1
Backend разработчик
Самое читаемое Разработка

Комментарии (7)

  • 0

    А explain итогового запроса покажите?

  • 0
    Честно, больше нравятся названия операторов из sequelize
    Как по мне фильтры для фильтрации данных, а для выборки из базы это операторы
  • +1
    Форма и action выглядят как адское месиво, может для вас это и удобно, но поддерживать такой код «не очень удобно»
    • 0
      Полностью соглашусь.
      Но какой смысл было рефакторить, создавать сервисы для всего этого дела, если задача стояла — максимально просто объяснить, как можно сделать фильтрацию данных из формы?
      Вообще, у меня бы это выглядело примерно так — https://gist.github.com/anboo/b01a49240323e5ad44b75c0372dc3810
      Можно услышать ваше мнение по-поводу этого?
      • 0
        Максимально просто можно было сделать и без месива, как пример:

        В контроллере имеем лишь entity-фильтра, форму, пагинатор.
        В репозиторий::getList передаем entity-фильтра:
                $leadFormFilterEntity = new LeadFormFilterEntity($this->getClock());
        
                $formFilter = $this->createForm(LeadFilterType::class, $leadFormFilterEntity);
                $formFilter->handleRequest($request);
        
                $pagination = $this->getPaginator()->paginate(
                    $leadsRepository->getList($leadFormFilterEntity),
                    $request->query->get('page', 1),
                    self::PER_PAGE
                );
        


        В методе репозитория getList имеем следующее:
            public function getList(LeadFormFilterEntity $leadFormFilterEntity)
            {
                return new ClosureQuery(
                    function () use ($leadFormFilterEntity) {
                        $query = $this
                            ->getQueryBuilder()
                            ->select('COUNT(abstractLead)');
        
                        $this->applyFilter($leadFormFilterEntity, $query);
        


        И только в applyFilter подстраиваем наш запрос под хотелки фильтра:
            private function applyFilter(LeadFormFilterEntity $leadFormFilterEntity, QueryBuilder $query)
            {
                if ($leadFormFilterEntity->getLanding()) {
                    $query
                        ->andWhere('abstractLead.landing = :landing')
                        ->setParameter('landing', $leadFormFilterEntity->getLanding());
                }
        


        При этом форма фильтра у нас чистая. В контроллере нет ничего лишнего. Вся логика расширения DQL запроса хранится в репозитории.

        Как-то так.
  • 0
    meet.startDate <= '2017-01-31 00:00:00'
    

    Получается встреча в 01-31 01:00:00 не попадет?
    • 0
      Вообще по логике да, в статье чисто пример DQL.
      Тут речь скорее о том, что конкретно данные поставляет в обработку с фрондента, в данном случае это была просто форма с datepicker-ом, прибавляем к нему timepicker и у нас все замечательно работает.
      В примере с DQL данные проставлялись в формате DATE, а не DATETIME, поэтому время было равно 00:00.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.