Пишем кастомный ParamConverter в Symfony2

Пишем кастомный ParamConverter в Symfony2

В фреймворке Symfony2 есть отличный компонент ParamConverter, который преобразовывает параметры из адресной строки в переменные PHP. Когда не хватает функционала, который он предоставляет из коробки, приходится его расширять. В этой статье на конкретном примере я покажу как создать конвертер параметров на Symfony2.

Допустим у нас есть две сущности, связанные как «один-ко-многим». Например:

  • Страны и города;
  • Районы и сёла.

Ключевой момент в том, что название города или села не уникальное. Ведь существуют одинаковые по названию города в разных странах. Например, Одесса есть не только в Украине, но и в штате Техас (США). Популярное название для села — Первомайское, встречается в многих областях Украины несколько раз, сёла с таким же названием есть и в России, и в Молдавии, и в Казахстане. Чтобы идентифицировать конкретное село, нужно указать полный адрес:

  • Украина, АРК Крым, Симферопольский район, село Первомайское;
  • Казахстан, Актюбинская область, Каргалинский район, село Первомайское;
  • Россия, Московская область, Истринский район, село Первомайское.

Для примера, давайте возьмем только два уровня: район и село. Так будет проще и понятнее, остальное можно будет расширить по аналогии. Допустим, для какой-то области нужно сделать сайт, на котором будет информация о каждом районе и каждом селе каждого района. Информация о селе выводится на отдельной странице. Требования, чтоб адрес страницы был читабельный и состоял из «слага» района и «слага» села. (Слаг, он же slug — это читабельное представление сущности в урле, для тех, кто не в теме). Вот примеры урлов, которые должны работать по схеме «Адрес сайта / слаг района / слаг села»:

  • example.com/yarmolynetskyi/antonivtsi
  • example.com/yarmolynetskyi/ivankivtsi
  • example.com/vinkovetskyi/ivankivtsi

yarmolynetskyi — слаг для Ярмолинецкого района Хмельницкой области. vinkovetskyi — Виньковецкий район. ivankivtsi — село Иванковцы, которое есть в обоих районах. antonivtsi — Антоновцы, еще одно село.

Опишем обе сущности с минимальным необходим количеством полей для данного примера.

namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
 
/**
 * @ORM\Entity()
 * @ORM\Table(name="districts")
 * @DoctrineAssert\UniqueEntity("slug")
 */
class District
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    private $id;
 
    /**
     * @ORM\OneToMany(targetEntity="Village", mappedBy="district", cascade={"persist", "remove"}, orphanRemoval=true)
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $villages;
 
    /**
     * @ORM\Column(type="string", unique=true, length=100, nullable=false)
     */
    private $slug;
 
    // Дальше идут сеттеры и геттеры для полей
}
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
 
/**
 * @ORM\Entity(repositoryClass="AppBundle\Repository\VillageRepository")
 * @ORM\Table(name="villages",
 *   uniqueConstraints={@ORM\UniqueConstraint(name="unique_village_slug_for_district", columns={"district_id", "slug"})}
 * )
 * @DoctrineAssert\UniqueEntity(fields={"district", "slug"}, errorPath="slug")
 */
class Village
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    private $id;
 
    /**
     * @ORM\ManyToOne(targetEntity="District", inversedBy="villages")
     * @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
     */
    private $district;
 
    /**
     * @ORM\Column(type="string", length=100, nullable=false)
     */
    private $slug;
 
    // Далее идут сеттеры и геттеры для полей
}

Внутри контроллера для района добавим экшен для вывода сёл.

namespace AppBundle\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
 
class DistrictController extends Controller
{
    /**
     * Показать информацию о селе конкретного района
     *
     * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
     * @Method({"GET"})
     */
    public function villageAction()
    {
        // ...
    }
}

Теперь рассмотрим, какие могут быть реализации нашей задачи. Первый вариант: нужно создать новый метод в VillageRepository, который выполнит JOIN с таблицей районов и найдет село по его слагу и по слагу района.

namespace AppBundle\Repository;
 
use AppBundle\Entity\Village;
use Doctrine\ORM\EntityRepository;
 
class VillageRepository extends EntityRepository
{
    /**
     * Поиск села по слагу и слагу района
     *
     * @param string $districtSlug Слаг района
     * @param string $villageSlug  Слаг села
     *
     * @return Village|null
     */
    public function findBySlugAndDistrictSlug($districtSlug, $villageSlug)
    {
        $qb = $this->createQueryBuilder('v');
 
        return $qb->join('v.district', 'd')
                  ->where($qb->expr()->eq('v.slug', ':village_slug'))
                  ->andWhere($qb->expr()->eq('d.slug', ':district_slug'))
                  ->setParameters([
                      'district_slug' => $districtSlug,
                      'village_slug'  => $villageSlug
                  ])
                  ->getQuery()
                  ->getOneOrNullResult();
    }
}

Метод в контроллере будет выглядеть так:

/**
 * @param string $districtSlug Слаг района
 * @param string $villageSlug  Слаг села
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 */
public function villageAction($districtSlug, $villageSlug)
{
    $villageRepository = $this->getDoctrine()->getRepository('AppBundle:Village');
 
    $village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
 
    if (null === $village) {
        throw $this->createNotFoundException('No village was found');
    }
 
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

Часть кода по поиску нужного элемента в базе можно заменить с помощью аннотации Symfony2 — @ParamConverter. Одна из плюшек этой аннотации в том, что exception вызовется сам, если сущности не будут найдены, нам не нужно ничего дополнительно проверять, а значит меньше кода, а значит код чище.

/**
 * @param District $district Район
 * @param Village  $village  Село
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 *
 * Просто как пример, но это не будет выполнять то, что нам надо!
 * @ParamConverter("district", class="AppBundle:District", options={"mapping": {"districtSlug" = "slug"}})
 * @ParamConverter("village", class="AppBundle:Village", options={"mapping": {"villageSlug" = "slug"}})
 */
public function villageAction($district, $village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

По умолчанию ParamConverter мапит параметры из урла в поле id указаного класса. Если входящие параметры — это не айдишки, то это нужно дополнительно указать через опцию mapping. Но на самом деле приведенный выше код не будет выполнять то, что нам надо. После того, как создать конвертер на symfony2б он найдет нужный район и запишет его в переменную $district. Второй конвертер найдет первое село с указанным слагом и запишет его в переменную $village. В запросе поиска сущности, который делает ParamConverter, стоит LIMIT 1, поэтому будет найден только один объект, который имеет наименьшую айдишку. Нам такое не подходит, для одинаковых сел — слаги одинаковые, а тут каждый раз будет находиться первое село из базы.

Двигаемся дальше. ParamConverter из коробки дает возможность смапить на одну сущность несколько полей, например: @ParamConverter("village", options={"mapping": {"code": "code", "slug": "slug"}}), но только если эти поля родные, т.е. принадлежат этой сущности. В нашем случае два слага принадлежат разным сущностям. Хотелось бы, чтоб из коробки заработала вот такая конструкция:

/**
 * @param Village $village Село
 *
 * @return Response
 *
 * Просто пример того, что хотелось бы, но чего пока нету!
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @ParamConverter("village", class="AppBundle:Village", options={"mapping": {"villageSlug" = "slug", "districtSlug" = "district.slug"}})
 */
public function villageAction($village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

Чтобы ParamConverter увидел, что мы мапим districtSlug на поле slug сущности District, которая мапится на поле district сущности Village, т.е. чтоб сджоинил две таблицы villages и districts. Но, к сожалению, из коробки пока такое сделать невозможно. Но зато есть возможность написать свой ParamConverter. Привожу полный код конвертера, который нам нужен, с комментариями между строк.

namespace AppBundle\Request\ParamConverter;
 
use AppBundle\Entity\Village;
use Doctrine\Common\Persistence\ManagerRegistry;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
class DistrictVillageParamConverter implements ParamConverterInterface
{
    /**
     * @var ManagerRegistry $registry Manager registry
     */
    private $registry;
 
    /**
     * @param ManagerRegistry $registry Manager registry
     */
    public function __construct(ManagerRegistry $registry = null)
    {
        $this->registry = $registry;
    }
 
    /**
     * {@inheritdoc}
     *
     * Проверяем или модифицированный конвертер параметров на симфони 2 поддерживает объект
     */
    public function supports(ParamConverter $configuration)
    {
        // Если ни один entity manager не обнаружен, значит сконфигурован только Doctrine DBAL
        // В этом случае больше мы ничего сделать не можем и уходим отсюда
        if (null === $this->registry || !count($this->registry->getManagers())) {
            return false;
        }
 
        // Проверям или в параметрах роута указана опция class
        if (null === $configuration->getClass()) {
            return false;
        }
 
        // Находим актуальный entity manager для класса, с которым будем работать
        $em = $this->registry->getManagerForClass($configuration->getClass());
 
        // Проверям или указаный класс соответствует тому, что мы хотим
        if ('AppBundle\Entity\Village' !== $em->getClassMetadata($configuration->getClass())->getName()) {
            return false;
        }
 
        return true;
    }
 
    /**
     * {@inheritdoc}
     *
     * Обрабатываем объект и сохраняем его в реквесте
     *
     * @throws \InvalidArgumentException Если отсутствуют атрибуты роута
     * @throws NotFoundHttpException     Если не найден объект
     */
    public function apply(Request $request, ParamConverter $configuration)
    {
        $districtSlug = $request->attributes->get('districtSlug');
        $villageSlug  = $request->attributes->get('villageSlug');
 
        // Проверяем или указанные атрибуты присутствуют в роуте
        if (null === $districtSlug || null === $villageSlug) {
            throw new \InvalidArgumentException('Route attribute is missing');
        }
 
        // Находим актуальный entity manager для класса, с которым будем работать
        $em = $this->registry->getManagerForClass($configuration->getClass());
 
        /** @var \AppBundle\Repository\VillageRepository $villageRepository Village repository */
        $villageRepository = $em->getRepository($configuration->getClass());
 
        // Пробуем найти село по его слагу и слагу его района
        $village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
 
        // Проверяем или нашли, если нет - кидаем подходящий Exception
        if (null === $village || !($village instanceof Village)) {
            throw new NotFoundHttpException(sprintf('%s object not found.', $configuration->getClass()));
        }
 
        // Мапим найденное село к параметрам роута
        $request->attributes->set($configuration->getName(), $village);
    }
}

Кастомный ParamConverter должен наследовать интерфейс ParamConverterInterface. В нем нужно реализовать два метода:

  • supports в котором проверяем или должен наш конвертер обрабатывать данный реквест;
  • apply в котором выполняем необходимые преобразования.

На сайте Symfony по написанию своего конвертера информации приведено по минимуму, за основу предлагают брать класс DoctrineParamConverter.

Чтоб наш кастомный конвертор заработал, его также нужно объявить, как сервис c тегом request.param_converter:

services:
    app.param_converter.district_village_converter:
        class: AppBundle\Request\ParamConverter\DistrictVillageParamConverter
        tags:
            - { name: request.param_converter, converter: district_village_converter }
        arguments:
            - @?doctrine

Аргумент @?doctrine означает, что если entity manager не сконфигурирован, то проигнорировать его.

Управлять очередностью срабатывания конвертеров можно с помощью параметра priority (об этом в документации на сайте Symfony). Или же можно указать название для конвертера converter: district_village_converter. Если нужно, чтоб запускался только наш конвертер, тогда прописываем его название в аннотации, другие конвертеры будут игнорироваться для данного реквеста.

Теперь код нашего экшена выглядит вот так:

/**
 * @param Village $village Село
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @Method({"GET"})
 *
 * @ParamConverter("village", class="AppBundle:Village", converter="district_village_converter")
 */
public function villageAction(Village $village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

Как видим, мы вынесли код по поиску сущности из контроллера в конвертер, код стал чище и приятнее.