Пишемо кастомний 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.

Щоб наш кастомний конвертор запрацював, його також необхідно оголосити, як сервіс з тегом 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
    ]);
}

Як бачимо, ми винесли код з пошуку сутності з контролера в конвертер, код став чистішим і приємнішим.