segunda-feira, 6 de agosto de 2012

MultiModel forms, parte 3: Nested Forms

Este post é o terceiro de uma série de quatro artigos:
O código desta aplicação de exemplo está disponível neste repositório Github.

Nested Forms

Nos dois primeiros posts desta série, foi mostrado o poder do uso de Nested Models. O Rails fornece ainda helpers para a construção de views com este recurso. Usamos estes helpers para reescrever o arquivo app/views/people/_form.html.erb (criado via scaffolding, no primeiro post):
<%= form_for(@person) do |f| %>
  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>

  <%= f.fields_for :user_account do |acc| %>
    <div class="field">
      <%= acc.label :username %><br />
      <%= acc.text_field :username %>
    </div>
    <div class="field">
      <%= acc.label :password %><br />
      <%= acc.password_field :password %>
    </div>

    <%= acc.fields_for :permissions do |p| %>
      <div class="field">
        <%= p.label p.object.restricted_area %>
        <%= p.hidden_field :restricted_area %>
        <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
      </div>
    <% end %>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>
Entre na tela de cadastro (http://localhost:3000/people/new), para visualizar um formulário contendo os campos de Person, UserAccount e Permission:

Oops. Apenas os campos de Person estão visíveis. Não é difícil conjecturar o porquê. Ao criar um novo objeto Person, ele não possui um user_account, como pode ser confirmado no console:
> Person.new.user_account
 => nil
É necessário sobrescrever o método Person/user_account, para que retorne um novo objeto quando Person não tiver uma conta de usuário associada:
  def user_account
    @user_account = super
    @user_account = build_user_account if @user_account.nil?
    @user_account
  end
Isto é suficiente para habilitar os campos de UserAccount:


Para exibir os campos referentes aos atributos de Permission, criamos um método UserAccount/permissions_types, que pré-instancia as possíveis opções:
  def permissions_types
    hash = {}
    permissions.each { |p| hash[p.restricted_area] = p }

    [ "admin", "backups", "logs" ].each { |area|
      hash[area] = Permission.new restricted_area: area unless hash.has_key? area
    }
    hash.values
  end
Note que as duas primeiras linhas "memorizam" associações previamente salvas, enquanto nas linhas seguintes, são construídos novos objetos apenas para as associações que ainda não estiverem presentes.

É necessário modificar o formulário para construir os campos de permissões utilizando este método:
<%= acc.fields_for :permissions, acc.object.permissions_types do |p| %>
  <div class="field">
    <%= p.label p.object.restricted_area %>
    <%= p.hidden_field :restricted_area %>
    <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
  </div>
<% end %>
Vejamos como ficou:

Agora que os campos já estão presentes no formulário, basta salvar um registro:

Oops... estamos quase lá... Vamos tentar entender o que aconteceu. Com auxílio do Chrome Developer Tools, podemos analisar os dados que são submetidos com o formulário:


Os view helpers do Rails criam campos com nomes que são adequados à sua forma de processar nested models. Se analisarmos o log do controller, vemos que o request foi convertido para um hash, representando estes parâmetros de forma hierárquica (formatei o log para facilitar sua visualização):
Started POST "/people" for 127.0.0.1 at 2012-08-05 22:36:11 -0300
Connecting to database specified by database.yml
Processing by PeopleController#create as HTML
Parameters: {
  "utf8"=>"✓", 
  "authenticity_token"=>"iTFVyyIwp0w2apXT02s+XN3z6PfOFEuUgZRaZWxPegU=",
  "person"=>{
    "name"=>"José da Silva",
    "user_account_attributes"=>{
      "username"=>"jsilva",
      "password"=>"[FILTERED]",
      "permissions_attributes"=>{
        "0"=>{
          "restricted_area"=>"admin",
          "grants"=>"Read-Only"
        },
        "1"=>{
          "restricted_area"=>"backups",
          "grants"=>""
        },
        "2"=>{
          "restricted_area"=>"logs",
          "grants"=>"Read-Write"
        }
      }
    }
  },
  "commit"=>"Create Person"
}
Faz sentido: o rails tentou criar uma permission com o campo grants em branco, mas este campo é obrigatório (vide a parte 2 desta série). É necessário alguma forma de filtrar de permission_attributes os valores que estiverem com grants em branco. Há: Rails possui essa facilidade:
accepts_nested_attributes_for :permissions, allow_destroy: true,
  reject_if: proc { |attrs|
    attrs["grants"].blank?
  }
Basta este pequeno ajuste em UserAccount para conseguimos salvar o registro, que na figura abaixo, foi reaberto para edição:


É incrível: simplesmente funciona! Tudo bem até aqui, já conseguimos salvar. Mas logo descobrimos que o "brinquedo quebra" se tentar remover uma permissão: tente deixar Logs em branco e salvar. Parece que tudo deu certo, mas ao reabrir para edição, a permissão continua lá.

Isto ocorre pois, após termos adicionado a cláusula reject_if, as permissões em branco passaram a ser rejeitadas. O Rails não tem mais a oportunidade de processá-las. Além disso, vimos nos posts anteriores que, para remover um nested model, é necessário passar _destroy: true como um de seus parâmetros.

Pode-se usar um checkbox para adicionar um atributo _destroy:
<%= acc.fields_for :permissions, acc.object.permissions_types do |p| %>
  <%= p.label p.object.restricted_area %>
  <%= p.hidden_field :restricted_area %>
  <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
  <% unless p.object.new_record? %>
    <%= p.check_box :_destroy %>
    <%= p.label :_destroy, 'Remove' %>
  <% end %>
<% end %>
O formulário fica com a seguinte aparência:


E agora, com auxílio do checkbox, conseguimos também remover permissões. Um único formulário, manipulando uma complexa estrutura de dados associados - Person 1 : 1 UserAccount 1 : N Permission.

Note que não foi necessário adicionar nenhuma lógica no controller para interpretar argumentos do request dos objetos associados. Apesar de termos sido obrigados a adicionar um checkbox para remoção de permissões, não precisamos de nenhuma lógica customizada de data-binding para apresentar as associações na view. Tudo out-of-the-box, prontinho para usar, um bonus-track desta fantástica framework web que é o Rails!

No quarto e último post desta série, será mostrada "a cereja do bolo": com um pouquinho de JavaScript, jQuery e Knockout.js, tornaremos a adição de permissões mais dinâmica, além de adicionar dinamicamente o atributo _destroy no request, sem precisar de um checkbox.


Referências

domingo, 5 de agosto de 2012

MultiModel forms, parte 2: One-To-Many

Este post é o segundo de uma série de quatro artigos:
O código desta aplicação de exemplo está disponível neste repositório Github.

Nested Models - One-to-Many

Segue o modelo utilizado neste tutorial:
  • Person has one UserAccount (parte 1)
  • UserAccount has many Permissions (parte 2)

Conforme estabelecido na parte 1 desta série, queremos editar UserAccount a partir do model Person. Nesta segunda parte, veremos como editar também a partir do model Person a associação one-to-many UserAccount / Permissions. O modelo de permissões será bem simples:

rails g model Permission user_account_id:integer restricted_area:string grants:string

rake db:migrate


Por fim, resta configurar as validações e relacionamentos nos respectivos models.

Nested One-to-Many

É necessário adicionar uma cláusula has_many em UserAccount, para que esta possa navegar na associação um-para-muitos. Permission possui a chave estrangeira user_account_id (lado belongs_to). Por fim, configuramos UserAccount para aceitar também os atributos da associação Permission.

class UserAccount < ActiveRecord::Base
  attr_accessible :password, :username, :permissions_attributes
  validates_presence_of :username, :password, :person
  belongs_to :person
  has_many :permissions, inverse_of: :user_account
  accepts_nested_attributes_for :permissions, allow_destroy: true
end

class Permission < ActiveRecord::Base
  attr_accessible :restricted_area
  validates_presence_of :user_account, :restricted_area, :grants
  belongs_to :user_account
end


Façamos alguns testes no console do Rails:

> u = UserAccount.first
  UserAccount Load (0.2ms)  SELECT "user_accounts".* FROM "user_accounts" LIMIT 1
 => #<UserAccount id: 1, person_id: 1, username: "jsilva", password: "abc123", created_at: "2012-08-05 04:02:34", updated_at: "2012-08-05 04:02:34"> 


> u.update_attributes permissions_attributes: [ { restricted_area: :admin, grants: "Read-Write" }, { restricted_area: :backup, grants: "Read-Only" } ]
   (0.3ms)  begin transaction
  Person Load (0.3ms)  SELECT "people".* FROM "people" WHERE "people"."id" = 1 LIMIT 1
  UserAccount Load (0.2ms)  SELECT "user_accounts".* FROM "user_accounts" WHERE "user_accounts"."id" = 1 LIMIT 1
  UserAccount Load (0.1ms)  SELECT "user_accounts".* FROM "user_accounts" WHERE "user_accounts"."id" = 1 LIMIT 1
  SQL (12.6ms)  INSERT INTO "permissions" ("created_at", "restricted_area", "updated_at", "user_account_id") VALUES (?, ?, ?, ?)  [["created_at", Sun, 05 Aug 2012 04:54:31 UTC +00:00], ["grants", "Read-Write"], ["restricted_area", :admin], ["updated_at", Sun, 05 Aug 2012 04:54:31 UTC +00:00], ["user_account_id", 1]]
  SQL (0.2ms)  INSERT INTO "permissions" ("created_at", "restricted_area", "updated_at", "user_account_id") VALUES (?, ?, ?, ?)  [["created_at", Sun, 05 Aug 2012 04:54:31 UTC +00:00], ["grants", "Read-Only"], ["restricted_area", :backup], ["updated_at", Sun, 05 Aug 2012 04:54:31 UTC +00:00], ["user_account_id", 1]]
   (58.7ms)  commit transaction


No exemplo acima, permissões foram adicionadas a uma conta existente de usuário. É possível ir além: todos os dados de UserAccount e Permission podem ser diretamente manipulados um nível acima, numa chamada de Person:

p = Person.create name: "João Oliveira", user_account_attributes: { username: "joliv", password: "secret", permissions_attributes: [ { restricted_area: "admin", grants: "Read-Write" }, { restricted_area: "backups", grants: "Read-Only" } ] }
   (0.1ms)  begin transaction
   (0.0ms)  commit transaction
   (0.1ms)  begin transaction
  SQL (36.8ms)  INSERT INTO "people" ("created_at", "name", "updated_at") VALUES (?, ?, ?)  [["created_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["name", "João Oliveira"], ["updated_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00]]
  SQL (0.6ms)  INSERT INTO "user_accounts" ("created_at", "password", "person_id", "updated_at", "username") VALUES (?, ?, ?, ?, ?)  [["created_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["password", "secret"], ["person_id", 3], ["updated_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["username", "joliv"]]
  SQL (0.4ms)  INSERT INTO "permissions" ("created_at", "grants", "restricted_area", "updated_at", "user_account_id") VALUES (?, ?, ?, ?)  [["created_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["grants", "Read-Write"], ["restricted_area", "admin"], ["updated_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["user_account_id", 3]]
  SQL (0.2ms)  INSERT INTO "permissions" ("created_at", "grants", "restricted_area", "updated_at", "user_account_id") VALUES (?, ?, ?, ?)  [["created_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["grants", "Read-Only"], ["restricted_area", "backups"], ["updated_at", Sun, 05 Aug 2012 05:08:31 UTC +00:00], ["user_account_id", 3]]
   (71.4ms)  commit transaction

Aprecie o poder deste recurso: uma simples linha de comando, e numa única transação são persistidos 4 objetos: Person, UserAccount e duas Permission. Não pára por aí. Este poder é ainda mais amplo, visto que Rails fornece helpers para construção de formulários com nested models - tema que será abordado na parte 3 desta série.

Referências

quarta-feira, 1 de agosto de 2012

Um formulário, multiplos modelos: a praticidade dos MultiModel Forms

MultiModel forms são um poderoso recurso oferecido pelo Rails desde a versão 2.3 - porém desconhecido de muitos programadores. Com eles, é possível editar complexas hierarquias de objetos numa única view. É uma situação que nos deparamos com bastante freqüência:
  • Editar Carrinho de Compras e Itens do Carrinho
  • Dados de uma Pessoa e Informações de Contato
  • Dados de um Usuário e Permissões.
  • Post num blog e suas Tags.
e por aí vai. Este post é o primeiro de uma série de quatro artigos, para ilustrar a utilização destes recursos:
O código desta aplicação de exemplo está disponível neste repositório Github.

Nested Models - One-to-One

Segue o modelo utilizado neste tutorial:
  • Person has one UserAccount
  • UserAccount has many Permissions (parte 2)
Em nome da simplicidade, Person será criado via scaffolding:

rails g scaffold Person name:string

Neste ponto, pode-se rodar as migrations e iniciar o servidor de desenvolvimento:

rake db:migrate
rails s

E já estará disponível a tela - bastante rudimentar - para a edição de Person:
Como queremos editar UserAccount a partir do model Person, dispensaremos scaffold, sendo necessário criar o respectivo modelo:

rails g model UserAccount person_id:integer username:string password:string

rake db:migrate


Por fim, resta configurar as validações e relacionamentos nos respectivos models.

Nested One-to-One

A relação entre os modelos Person e UserAccount é de um-para-um, sendo que UserAccount possui a chave estrangeira person_id (lado belongs_to):

class Person < ActiveRecord::Base
  attr_accessible :name

  validates_presence_of :name
  has_one :user_account
end


class UserAccount < ActiveRecord::Base
  attr_accessible :password, :username

  validates_presence_of :username, :password
  belongs_to :person
end


Podemos usar o rails console para um teste inicial:

$ rails c
> p = Person.create name: "José da Silva"

(0.1ms) begin transaction
SQL (16.4ms) INSERT INTO "people" ("created_at", "name", "updated_at") VALUES (?, ?, ?) [["created_at", Tue, 31 Jul 2012 05:57:41 UTC +00:00], ["name", "José da Silva"], ["updated_at", Tue, 31 Jul 2012 05:57:41 UTC +00:00]]
(59.6ms) commit transaction


No exemplo acima, foi criado um objeto Person, passando para o método create um hash contendo apenas o atributo name.

O uso de nested models possibilita que a chamada create receba também os atributos username e password, para criar uma UserAccount simultaneamente ao objeto Person.

class Person < ActiveRecord::Base
  attr_accessible :name, :user_account_attributes
  validates_presence_of :name

  has_one :user_account
  accepts_nested_attributes_for :user_account
end


Note que é necessário tornar esses atributos acessíveis via mass-assignment, na lista attr_accessible.

> p = Person.create name: "José da Silva", user_account_attributes: { username: "jsilva", password: "abc123" }

(0.1ms) begin transaction
(0.1ms) commit transaction
(0.0ms) begin transaction
SQL (6.1ms) INSERT INTO "people" ("created_at", "name", "updated_at") VALUES (?, ?, ?) [["created_at", Tue, 31 Jul 2012 06:23:26 UTC +00:00], ["name", "José da Silva"], ["updated_at", Tue, 31 Jul 2012 06:23:26 UTC +00:00]]
SQL (0.4ms) INSERT INTO "user_accounts" ("created_at", "password", "person_id", "updated_at", "username") VALUES (?, ?, ?, ?, ?) [["created_at", Tue, 31 Jul 2012 06:23:26 UTC +00:00], ["password", "abc123"], ["person_id", 5], ["updated_at", Tue, 31 Jul 2012 06:23:26 UTC +00:00], ["username", "jsilva"]]
(56.6ms) commit transaction


Validação

O leitor mais atento pode ter notado que UserAccount não está validando a presença de Person. Isto deixa uma brecha no código, por permitir a criação de uma UserAccount desassociada de uma pessoa:

> UserAccount.create username: "joao", password: "abc123"

(0.2ms) begin transaction
SQL (1.4ms) INSERT INTO "user_accounts" ("created_at", "password", "person_id", "updated_at", "username") VALUES (?, ?, ?, ?, ?) [["created_at", Tue, 31 Jul 2012 06:25:34 UTC +00:00], ["password", "abc123"], ["person_id", nil], ["updated_at", Tue, 31 Jul 2012 06:25:34 UTC +00:00], ["username", "joao"]]
(33.3ms) commit transaction


Porém isto não faz sentido no nosso modelo. Façamos a correção:

class UserAccount < ActiveRecord::Base
  attr_accessible :password, :username

  validates_presence_of :person, :username, :password
  belongs_to :person
end


Agora UserAccount passa se comportar conforme esperado:

> u = UserAccount.create username: "a", password: "b"

(0.2ms) begin transaction
(0.1ms) rollback transaction

> u.errors.messages
=> {:person=>["can't be blank"]}


Porém, isto tem um efeito colateral: ela afeta a criação da user_account via nested attributes de Person:

> p = Person.create name: "José da Silva", user_account_attributes: { username: "jsilva", password: "abc123" }
(0.1ms) begin transaction
(0.1ms) commit transaction
(0.1ms) begin transaction
(0.1ms) rollback transaction
> p.errors.messages
=> {:"user_account.person"=>["can't be blank"]}


Para evitar que ocorra a validação quando são usados nested attributes, deve-se informar para Person não validar o atributo person da associação user_account:

class Person < ActiveRecord::Base
  attr_accessible :name, :user_account_attributes
  validates_presence_of :name

  has_one :user_account, inverse_of: :person
  accepts_nested_attributes_for :user_account
end


Update

Para uma operação de atualização, é necessário passar em user_account_attributes o id da user_account previamente criada:

> p.update_attributes user_account_attributes: { id:6, username: "jsilva11", password: "123456" }
(0.1ms) begin transaction
(0.6ms) UPDATE "user_accounts" SET "username" = 'jsilva11', "updated_at" = '2012-07-31 07:43:20.649645' WHERE "user_accounts"."id" = 6
(49.9ms) commit transaction

CUIDADO: caso o id fosse omitido, seria feito um novo insert na tabela user_accounts, e o objeto Person seria reassociado à nova user_account.

Destroy

Para completar este tutorial, suponhamos que faça sentido remover uma user_account, sem remover os respectivos dados de person. Isto pode ser obtido marcando o nested_model com allow_destroy:

class Person < ActiveRecord::Base
  attr_accessible :name, :user_account_attributes

  validates_presence_of :name
  has_one :user_account, inverse_of: :person

  accepts_nested_attributes_for :user_account, allow_destroy: true
end


Agora, pode-se remover user_account passando _destroy: true no hash user_account_attributes:

> p.update_attributes user_account_attributes: { id:6, _destroy: true }
(0.2ms) begin transaction
UserAccount Load (0.1ms) SELECT "user_accounts".* FROM "user_accounts" WHERE "user_accounts"."person_id" = 7 LIMIT 1
SQL (4.9ms) DELETE FROM "user_accounts" WHERE "user_accounts"."id" = ? [["id", 6]]
(47.8ms) commit transaction

> p.reload.user_account
Person Load (0.3ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT 1 [["id", 7]]
UserAccount Load (1.9ms) SELECT "user_accounts".* FROM "user_accounts" WHERE "user_accounts"."person_id" = 7 LIMIT 1
=> nil

Referências