Создание CRUD приложения на Symfony 2

Symfony 2.0

Недавно вышедшая версия фреймворка Symfony 2 включает в себя много интересных фич. В данной статье хочу рассказать про создание CRUD приложений — очень часто встречающейся задачи создания веб-интерфейса для создания, чтения, обновления и удаления записей в БД.

Про архитектуру и установку Symfony 2 уже было на хабре, поэтому считаем что Symfony 2 SE уже установлена и основные используемые понятия (бандлы, формы, шаблоны и т.д) вам знакомы.

Основные задачи, стоящие при разработке стандартного CRUD-приложения на Symfony 2

  1. Разработка модели данных
  2. Разработка контроллеров, форм и шаблонов позволяющих создавать, читать, обновлять и удалять сущности модели данных

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

Разработка модели данных

Создание описаний сущностей посредством ручного написания yml или xml достаточно утомительное занятие (хотя конечно можно воспользоваться плагином к Mysql Workbench или специализированным ПО). Для ускорения процесса в Symfony 2 есть удобное средство генерации описания модели данных путем реверс-инжиниринга существующей БД.

Для начала создаем бандл TestNewsBundle:

php app/console generate:bundle --namespace=Test/NewsBundle --format=annotation --structure 

Создаем схему БД:

SET FOREIGN_KEY_CHECKS=0; CREATE TABLE `news` ( `id` INT NOT NULL AUTO_INCREMENT , `news_category_id` INT NOT NULL , `title` VARCHAR(255) NULL , `announce` TEXT NULL , `text` TEXT NULL , `pub_date` DATE NULL , PRIMARY KEY (`id`) , INDEX `pub_date` (`pub_date` ASC) , INDEX `fk_news_news_category` (`news_category_id` ASC) , CONSTRAINT `fk_news_news_category` FOREIGN KEY (`news_category_id` ) REFERENCES `news_category` (`id` ) ON DELETE NO ACTION ON UPDATE NO ACTION) ENGINE = InnoDB; CREATE TABLE `news_category` ( `id` INT NOT NULL AUTO_INCREMENT , `name` VARCHAR(255) NULL , PRIMARY KEY (`id`) ) ENGINE = InnoDB CREATE TABLE `news_link` ( `id` int(11) NOT NULL AUTO_INCREMENT, `news_id` INT NOT NULL , `url` varchar(255) DEFAULT NULL, `text` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) , INDEX `fk_news_link_news1` (`news_id` ASC) , CONSTRAINT `fk_news_link_news1` FOREIGN KEY (`news_id` ) REFERENCES `news` (`id` ) ON DELETE NO ACTION ON UPDATE NO ACTION) ENGINE = InnoDB; 

Создаем классы doctrine:

php app/console doctrine:mapping:import TestNewsBundle annotation 

В результате выполнения этой команды в поддиректории Test/NewsBundle/Entity будут созданы по одному классу для каждой таблицы, при этом конфигурация маппинга объектов в реляционные таблицы для Doctrine ORM описаны в аннотациях классов, например класс News:

<?php namespace Test\NewsBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * Test\NewsBundle\Entity\News * * @ORM\Table(name="news") * @ORM\Entity */ class News { /** * @var integer $id * * @ORM\Column(name="id", type="integer", nullable=false) * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") */ private $id; /** * @var string $title * * @ORM\Column(name="title", type="string", length=255, nullable=true) */ private $title; /** * @var text $announce * * @ORM\Column(name="announce", type="text", nullable=true) */ private $announce; /** * @var text $text * * @ORM\Column(name="text", type="text", nullable=true) */ private $text; /** * @var date $pubDate * * @ORM\Column(name="pub_date", type="date", nullable=true) */ private $pubDate; /** * @var NewsCategory * * @ORM\ManyToOne(targetEntity="NewsCategory") * @ORM\JoinColumns({ *  @ORM\JoinColumn(name="news_category_id", referencedColumnName="id") * }) */ private $newsCategory; } 

Для дополнения классов геттерами и сеттерами выполняем команду:

php app/console doctrine:generate:entities TestNewsBundle 

Создание заготовки для CRUD — приложения

Cоздаем заготовку для работы с новостями с использованием команды doctrine:generate:crud. Формат роутинга — аннотации в файле контроллера (Test/NewsBundle/Controller/NewsController). Роутинг через аннотации в файле контроллера работает через бандл SensioFrameworkExtra (в поставке Symfony 2 SE он есть).

php app/console doctrine:generate:crud --entity=TestNewsBundle:News --route-prefix=news --with-write --format=annotation 

Теперь при заходе по указанному при генерации пути (если Symfony 2 распакован в wwwroot — http://localhost/Symfony/web/app_dev.php/news/) показывается пустой список новостей, а при нажатии на ссылку «Create new entry» — открывается форма создания записи по умолчанию.

Создадим такую же заготовку для работы с категориями новостей и занесем несколько категорий:

php app/console doctrine:generate:crud --entity=TestNewsBundle:NewsCategory --route-prefix=newscategory --with-write --format=annotation 

Для того чтобы у нас в дальнейшем была заготовка для формы добавления ссылки к новости аналогично сгенерируем заготовку для сущности NewsLink:

php app/console doctrine:generate:crud --entity=TestNewsBundle:NewsLink --route-prefix=newslink --with-write --format=annotation 

Чтобы при выводе списка категорий в форме добавления новости система знала какое поле показывать в селекте, в класс Test/NewsBundle/Entity/NewsCategory нужно добавить метод:

function __toString() { return $this->getName(); } 

Модификация класса формы

Теперь подправим сгенерированную форму Test/NewsBundle/Entity/NewsType. Добавим заголовки к полям (label), проставим нужные типы полей — text (отображается input type=text), textarea. В полях pubDate и newsCategory оставим тип поля null — в этом случае Symfony Form Component сам «угадывает» какой тип поля показать.

<?php namespace Test\NewsBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilder; class NewsType extends AbstractType { public function buildForm(FormBuilder $builder, array $options) { $builder->add('title', 'text', array('label' => 'Заголовок')) ->add('announce', 'textarea', array('label' => 'Анонс')) ->add('text', 'textarea', array('label' => 'Текст')) ->add('pubDate', null, array('label' => 'Дата новости')) ->add('newsCategory', null, array('label' => 'Категория')); } public function getName() { return 'news'; } } 

Модификация шаблона формы

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

{% form_theme form 'form_table_layout.html.twig' %} 

Однако нам в дальнейшем нужно будет переопределить стандартное отображение поля, поэтому шаблон занесения новости выглядит так:

{% use 'form_table_layout.html.twig' %} {% form_theme form _self %} <h1>Занесение новости</h1> <form action="{{ path('news_create') }}" method="post" {{ form_enctype(form) }}> {{ form_widget(form) }} <p> <button type="submit">Создать новость</button> </p> </form> <ul class="record_actions"> <li> <a href="{{ path('news') }}"> Назад к списку </a> </li> </ul> 

Форма отображается в табличной верстке:

Редактирование связанных записей

Теперь займемся самым интересным — редактированием ссылок к новости. При реверс-инжиниринге в классы сущностей была автоматически добавлена только связь «Ссылка -> Новость». Для того чтобы добавить связь «Новость -> Cсылки» нужно в классе Test\NewsBundle\Entity\News добавить:

/** * @ORM\OneToMany(targetEntity="NewsLink", mappedBy="news", cascade={"all"}) */ protected $newsLinks; function __construct() { $this->newsLinks = new Doctrine\Common\Collections\ArrayCollection(); } 

После чего выполнить команду (будут сгенерированы геттер и сеттер для атрибута $newsLinks):

php app/console doctrine:generate:entities TestNewsBundle 

Теперь в класс Test/NewsBundle/Entity/NewsType добавляем поле типа «Collection», позволяющее редактировать набор связанных сущностей:

$builder->add('title', 'text', array('label' => 'Заголовок')) .... ->add('newsLinks', 'collection', array( 'label' => 'Ссылки к новости', 'type' => new NewsLinkType(), 'allow_add' => true, 'allow_delete' => true, 'prototype' => true )); 

В классе Test\NewsBundle\Form\NewsLinkType обязательно нужно указать название класса редактируемой сущности (опция data_class):

<?php namespace Test\NewsBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilder; class NewsLinkType extends AbstractType { public function buildForm(FormBuilder $builder, array $options) { $builder->add('url') ->add('text'); } public function getName() { return 'newsLinkType'; } public function getDefaultOptions(array $options) { return array( 'data_class' => 'Test\NewsBundle\Entity\NewsLink', ); } } 

Однако если сейчас посмотреть форму — то будет отображен только заголовок поля «Ссылки к новости». Чтобы форма заработала нужно еще модифицировать шаблон. Заодно и вынесем определение формы в отдельный шаблон. Для этого создаем файл Test/NewsBundle/Resources/views/News/form.html.twig:

{% use 'form_table_layout.html.twig' %} {% form_theme form _self %} <script language="JavaScript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script> <form action="{{ entity.id ? path('news_update', { 'id': entity.id }) : path('news_create') }}" method="post" {{ form_enctype(form) }}> {{ form_errors(form) }} <table> {{ form_row (form.title) }} {{ form_row (form.announce) }} {{ form_row (form.text) }} {{ form_row (form.pubDate) }} {{ form_row (form.newsCategory) }} <tr> <td valign="top">Ссылки к новости</td> <td> <!-- Шаблон вывода строки с полем занесения/редактирования ссылки к новости --> {% macro linkRow(link) %} <tr> <td>{{ form_widget(link.url) }}</td> <td>{{ form_widget(link.text) }}</td> <td><a href="#" class="deleteRowLink">X</a></td> </tr> {% endmacro %} <!-- В этом контейнере находится шаблон строки занесения/редактирования ссылки к новости --> <!-- При нажатии на кнопку #addLink он добавляется к таблице --> <script type="text/html" id="nl">{{ _self.linkRow (form.newsLinks.get('prototype')) }} </script> <!-- Таблица в которой будет выводиться список занесенных ссылок --> <table id="linksTable"> <tr><td>Url</td><td>Название ссылки</td></tr> {% for key, link in form.newsLinks %} {{ _self.linkRow(link) }} {% endfor %} </table> <input type="button" id="addLink" value="Добавить ссылку"> <script> $(function() { $("#addLink" ).click(function() { $('#linksTable tbody').append($('#nl').html().replace(/\$\$name\$\$/g, $('#linksTable tbody tr').length)); }); $("form a.deleteRowLink").live('click', function() { $(this).closest('tr').remove(); }); }); </script> </td> </tr> </table> {{ form_rest(form) }} <p><button type="submit">Сохранить</button></p> </form> 

Шаблон занесения записи Test/NewsBundle/Resources/views/News/new.html.twig:

<h1>Занесение новости</h1> {% include 'TestNewsBundle:News:form.html.twig' %} <ul class="record_actions"> <li> <a href="{{ path('news') }}"> Назад к списку </a> </li> </ul> 

Шаблон редактирования записи Test/NewsBundle/Resources/views/News/edit.html.twig:

<h1>Редактирование новости</h1> {% include 'TestNewsBundle:News:form.html.twig' with { 'form' : edit_form } %} <ul class="record_actions"> <li> <a href="{{ path('news') }}"> Back to the list </a> </li> <li> <form action="{{ path('news_delete', { 'id': entity.id }) }}" method="post"> {{ form_widget(delete_form) }} <button type="submit">Delete</button> </form> </li> </ul> 

Можно конечно оставить один шаблон для занесения и редактирования записей, но в данном примере оставим структуру шаблонов и методов контроллера, которую сгенерировал CRUD-генератор, без изменений.

Далее нужно модифицировать класс котроллера Test/NewsBundle/Controller/NewsController. Хотя мы и указали в параметрах связи «Новость — Ссылка» опцию cascade = all (при сохранении сущности сохраняются связанные сущности), однако все равно требуется определить привязку объектов NewsLink к родительскому объекту News:

public function createAction() { $entity = new News(); $request = $this->getRequest(); $form = $this->createForm(new NewsType(), $entity); $form->bindRequest($request); if ($form->isValid()) { $em = $this->getDoctrine()->getEntityManager(); // Нужно указать родительский объект foreach ($entity->getNewsLinks() as $link) { $link->setNews($entity); } $em->persist($entity); $em->flush(); return $this->redirect($this->generateUrl('news_show', array('id' => $entity->getId()))); } return array( 'entity' => $entity, 'form' => $form->createView()); } 

Для метода updateAction нужно еще предусмотреть возможно удаления связанных записей. В html-форме есть удаление строк таблицы с использованием JQuery, однако этого недостаточно для удаления записей — это нужно явно реализовать в контроллере:

 public function updateAction($id) { $em = $this->getDoctrine()->getEntityManager(); $entity = $em->getRepository('TestNewsBundle:News')->find($id); if (!$entity) { throw $this->createNotFoundException('Unable to find News entity.'); } $beforeSaveLinks = $currentLinkIds = array(); foreach ($entity->getNewsLinks() as $link) $beforeSaveLinks [$link->getId()] = $link; $editForm = $this->createForm(new NewsType(), $entity); $deleteForm = $this->createDeleteForm($id); $request = $this->getRequest(); $editForm->bindRequest($request); if ($editForm->isValid()) { foreach ($entity->getNewsLinks() as $link) { $link->setNews($entity); //Если ссылка - не только что занесенная (у нее есть id) if ($link->getId()) $currentLinkIds[] = $link->getId(); } $em->persist($entity); //Если ссылка которая была до сохранения отсутствует в текущем наборе - удаляем ее foreach ($beforeSaveLinks as $linkId => $link) if (!in_array( $linkId, $currentLinkIds)) $em->remove($link); $em->flush(); return $this->redirect($this->generateUrl('news_edit', array('id' => $id))); } return array( 'entity' => $entity, 'edit_form' => $editForm->createView(), 'delete_form' => $deleteForm->createView(), ); } 

Форма с возможностью добавления связанных записей:

Форма с возможностью добавления связанных записей

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

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: