Создание мульти-модельных форм

Автор оригинала: Ryan
  • Перевод
Иногда требуется создать форму, данные которой связаны с несколькими таблицами. К примеру, у вас имеется две модели: Owner и Car. При добавлении нового Owner'a хотелось бы, чтобы была возможность сразу добавить машину. С появлением Rails 2.3 это стало проще.

# Старый вариант (приблизительный)<br/>def create<br/>  @owner = Owner.new(params[:owner])<br/>  ...<br/>  if @owner.save<br/>    @car = Car.new(params[:car])<br/>    if @car.save<br/>    ...<br/>end<br/><br/># Новый вариант, Rails 2.3+<br/>def create<br/>  @owner = Owner.new(params[:owner])<br/>  ...<br/>end

А магия в волшебном параметре accepts_nested_attributes_for, который прописывается в модели и при создании объекта передает дополнительные параметры. Создается все это одной строкой:

Owner.create(:name => "Шумахер", :age => 40, :car_attributes => <br/>                      {:model => "Formula 1", :color => "red"})


Тем самым мы создаем сразу две записи: об Owner'e и о машине.

Рассмотрим другой пример, более подробно: модель Person должна быть связана сама с собой — при добавлении персоны мы сделаем возможность «добавлять детей», которые тоже обращаются к этой модели.

Шаг 1: Сообщаем модели об использовании nested-атрибутов



Первое, что надо сделать для ассоциации, это добавить строку accepts_nested_attributes_for

сlass Person < ActiveRecord::Base <br/>  validates_presence_of :name <br/>  has_many :children, :class_name => 'Person' <br/>  accepts_nested_attributes_for :children, :allow_destroy => true <br/>    # также можно использовать с has_one и другими ассоциациями <br/>end


После этого вы сможете напрямую создавать, редактировать и удалять дочерние связи с объектом:
# К персоне можно добавить чадо:<br/>@person.children_attributes = [ { :name => 'Son' } ]<br/>@person.children #=> [ <#Person: name: 'Son'> ]<br/>@person.children.clear<br/># А теперь добавим сразу двух:<br/>@person.children_attributes =<br/>  [ { :name => 'Son' }, { :name => 'Daughter' } ]<br/>@person.save<br/>@person.children #=> [ <#Person: name: 'Son'>, <#Person: name: 'Daughter'> ]<br/># Редактирование сына (считая что его id == 1)<br/>@person.children_attributes = [ { :id => 1, :name => 'Lad' } ]<br/>@person.save<br/>  #=> Теперь сына зовут 'Lad'<br/># Добавим дочери имя (id == 2) и заодно создадим нового отпрыска:<br/>@person.children_attributes =<br/>  [ { :id => 2, :name => 'Lassie' }, { :name => 'Pat' } ]<br/>@person.save<br/>  #=> Теперь дочку зовут 'Lassie', и появился некто 'Pat'<br/># Удалим Pat'a (id = 3), нам он не нравится<br/>@person.children_attributes = [ :id => 3, '_destroy' => '1' } ]<br/>@person.save<br/>  #=> Pat теперь удален


Для поддержки создания и редактирования объектов, нам следует использовать массив хешей при ассоциации один-ко-многим, или просто хеш при ассоциации один-к-одному. Если параметр хеша :id не задан, то будет создан новый объект.

Для удаления уже существующего связанного объекта, используйте такой способ: [{ :id => pk, '_destroy' => '1' }], где параметр '_destroy' должен принимать любое значение true. Не забудьте установить опцию :allow_destroy в модели, по умолчанию она выключена.

В Rails 2.3.5 произошло переименование функции _destroy — ранее она называлась _delete. Не забывайте об этом, если работаете с устаревшей версией.

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

Шаг 2. Создаем форму со вложенной моделью



В view просто добавляем fields_for, и в ней пишем поля для этой модели:
<% form_for @person do |person_form| %><br/>  <%= person_form.label :name %><br/>  <%= person_form.text_field :name %><br/>  <% person_form.fields_for :children do |child_form| %><br/>    <%= child_form.label :name %><br/>    <%= child_form.text_field :name %><br/>    <% unless child_form.object.new_record? %><br/>       <%= child_form.check_box '_destroy' %><br/>       <%= child_form.label '_destroy', 'Remove' %><br/>    <% end %><br/>  <% end %><br/>  <%= submit_tag %><br/><% end %>


Код создаст форму со всеми необходимыми полями, которая пойдет на RESTful-контроллер, а оттуда будут незаметно переданы параметры children_attributes на модель. Если при создании «детей» возникнут ошибки, то они добавятся к  person.errors — в этом случае объект не сохранится в базе.

Несколько полезных заметок:
  • При использовании fields_for со связью :has_many можно стать жертвой рекурсии, неосмотрительно добавив несколько вложенных моделей друг в друга, которые связаны ассоциациями
  • Если вам нужно изменить что-то в объекте вложенной модели, вы можете получить к нему доступ, используя child_form.object. В примере выше мы использовали child_form.object.new_record? чтобы определить, следует ли показывать галочку «Удалить» (для новых записей она не нужна)

Шаг 3. Что прописывать в контроллере?.. Ничего



Третий шаг, наверное самый простой, потому что мы делаем все, не нарушая REST. Красота этого решения в том, что наши контроллеры не захламляются лишним кодом, которому место в модели. Просто посмотрите на эти методы создания и обновления:

class PersonController < ApplicationController<br/>  def create<br/>    @person = Person.new(params[:person])<br/>    @person.save ? redirect_to(person_path(@person)) : render(:action => :new)<br/>  end<br/>  def update<br/>    @person = Person.find(params[:id])<br/>    @person.update_attributes(params[:person]) ?<br/>      redirect_to(person_path(@person)) : render(:action => :edit)<br/>  end<br/>end

Как видите, все что мы прописали в модели и в форме просто работает, без лишних обработок в контроллере.

Дополнительно


Показываем все вложенные поля

Довольно часто требуется, чтобы вложенные поля отображались сразу. Например, если пользователь хочет создать новую персону и одновременно с этим добавить детей.

Из-за того что создаваемый обьект person создан только что, поля child_form не будут отображены. Есть два способа решения этой проблемы:
 — Построить новый объект в контроллере:
def new<br/>  @person = Person.new<br/>  @person.children.build<br/>  # ...<br/>end

 — Можно добавить хелпер, который делает практически то же самое, но располагается в другом месте:
module ApplicationHelper<br/>  def setup_person(person)<br/>    returning(person) do |p|<br/>      p.children.build if p.children.empty?<br/>    end<br/>  end<br/>end

После этого надо поменять form_for person на немного другую:
<% form_for setup_person(@person) do |person_form| %><br/>  <!-- ... --><br/><% end %>

Какой из этих способов использовать, каждый решает сам. Лично мне больше нравится первый, автору оригинальной статьи — второй.

Указываем, когда нам нужны вложенные модели

Если вы создаете форму, в которой по умолчанию есть вложенные поля (Person → children), то кто-нибудь рано или поздно попытается отправить форму с пустыми параметрами (без детей). Можно сделать, чтобы пользователь получал ошибку — если дети обязательны для заполнения, и возвращался назад. Или просто создать объект без детей.

Для этого есть опция :reject_if:
class Person < ActiveRecord::Base<br/>  validates_presence_of :name<br/>  has_many :children, :class_name => 'Person'<br/>  # Запрещаем создание детей, у которых оба поля не заполнены<br/>  accepts_nested_attributes_for :children,<br/>    :reject_if => proc { |attrs| attrs.all? { |k, v| v.blank? } }<br/>  # Тот же метод, но более изящный<br/>  accepts_nested_attributes_for :children, :reject_if => :all_blank<br/>end

# пустые параметры не создадут новую запись<br/>@person.children_attributes = [ { :name => '' } ]<br/>@person.save<br/>@person.children.count #=> 0

Эта опция также будет полезна, если у вас есть boolean-поля в модели, и checkbox на форме. Если его не отметить и не написать имя ребенка, на контроллер передастся лишь параметр '0', и :all_blank уже не поможет

class Person < ActiveRecord::Base<br/>  validates_presence_of :name, :bad<br/>  has_many :children, :class_name => 'Person'<br/>  # Это предотвратит добавление пользователей, у которых пустое имя, и чекбокс не был отмечен<br/>  accepts_nested_attributes_for :children,<br/>    :reject_if => proc { |attrs| attrs['bad'] == '0' && attrs['name'].blank? }<br/>  <br/>  # Можно было обойтись :reject_if => proc { |attrs| attrs['name'].blank? }<br/>  # чтобы запретить создавать детей без имени, отметив чекбокс (прим. перев.)<br/>end

@person.children_attributes = [ { :name => '', :bad => '0' } ]<br/>@person.save<br/>@person.children.count #=> 0

Динамически загружаемые поля


Если вы строите сложную форму, в которой находятся большое количество моделей, да еще и вложенных в несколько уровней, то можно просто сделать несколько вложенных форм видимыми по умолчанию. Хотя это и не совсем рационально. Более привлекательный вариант — это динамически добавлять новые поля с помощью JavaScript'a по запросу пользователя.

Отличное приложение-пример можно посмотреть на GitHub, автором которого является Eloy. Посмотрев на него, вы поймете как работает целая система моделей и связей между ними.
Поделиться публикацией

Комментарии 16

    0
    С ходу в избранное, спасибо большое!
    Код моей статьи прямо один-в-один этот ваш «старый вариант» :)
      +2
      Ментально заплюсовал (хабраслабость, видите-ли).
      Добавьте человеку кармы, чтобы перенести пост в blogs/ror.
        0
        спасибо за карму, перенес куда надо =)
          0
          Класс!
          А 2.3+ это прямо с 2.3.0?
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              Как раз собирался разобраться с nested attributes. Прочту на досуге до конца ;)
              • НЛО прилетело и опубликовало эту надпись здесь
                  0
                  ну он же и является автором оригинальной статьи, вроде так?
                  0
                  Супер!
                  Ещё бы кто-нибудь JS из примера переписал на jquery…
                    0
                    Пардон, там же и нашел. в форках.
                    0
                    прямо в точку. именно то, что сейчас надо!
                    Спасибо и с наступающим!
                      0
                      отлично, спасибо!
                        +1
                        Ещё уточнение/апдейт:

                        DEPRECATION WARNING: _delete is deprecated in nested attributes. Use _destroy instead… (called from _delete at /usr/lib64/ruby/gems/1.8/gems/activerecord-2.3.5/lib/active_record/nested_attributes.rb:263)
                          0
                          спасибо, поменял -)
                            0
                            Не спеши ты так:)

                            >ранее она называлась она называется
                          0
                          Отличная статья, сразу и теория и практика. Спасибо!

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

                          Самое читаемое