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

Nenhum comentário:

Postar um comentário