mercoledì 13 febbraio 2013

Rails e le Nested resources

Le “nested Resource” e le relative “Nested form” sono dei costrutti che permettono di aggiornare facilmente, con un unico submit da parte del browser, un modello e degli altri modelli ad esso collegati:

--- NB: L’obiettivo di questo articolo è spiegare la logica di ciò che c’è dietro le nested re source, ovvero il meccanismo in base a cui RoR permette di gestire le risorse ---

Le caratteristiche del framework Rails di "Convention over Configuration" rendono le applicazioni RoR "magiche" agli occhi di chi non lo conosce.

In realtà non c'è nulla di magico in tutto questo, ma si tratta di rispettare alcune convenzioni. Convenzioni che evolvono, spesso alla ricerca di maggiore protezione e sicurezza (vedi la direttiva attr_accessible) o rispettando il concetto di base che la maggioranza della community e degli sviluppatori fanno così.

Una delle "magie" che mi interessa discutere qui è quella delle nested resource, ma per completezza dobbiamo fare prima qualche passo indietro.

La creazione o l'aggiornamento di un modello fa largo uso degli hash. Ad esempio posso scrivere (ipotizzando che esista il team con id 1)
giocatore = Player.new(:firstname=>"Nome", :surname => "Cognome", :team_id=>"1")
oppure
parametri={:firstname=>"Nome", :surname => "Cognome", :team_id=>"1"}
giocatore= Player.new(parametri)
l’ActionController implementa questo concetto quando estrae dall’Hash params i dati con cui costruire le istanze dei modelli. Rispettando le convezioni RoR nei controller generati con lo scaffold ritroviamo
@player = Player.new(params[:player]) # nel create
@player.update_attributes(params[:player]) # nell'update
Il form HTML infatti è stato generato utilizzando il “model_name” come prefisso, e i dati saranno inviati nel formato:
player[firstname]=Nome;player[lastname]=Cognome;player[team_id]=1;commit=Salva.....
generando un hash di questo tipo
Parameters: {"utf8"=>"", "authenticity_token"=>".....", "player"=>{"lastname"=>"Cognome", "firstname"=>"Nome", "team_id"=>"1"}, "commit"=>"Update Player", "id"=>"1"}
Diventa chiaro come params[:player] rappresenti esattamente la variabile parametri che avevamo instanziato prima.
Arriviamo alle nested resource. Ipotizziamo 2 modelli, Team e Player in cui abbiamo le seguenti definizioni:
 class Team < ActiveRecord::Base
    attr_accessible :name
    has_many :players
 end
 class Player < ActiveRecord::Base
    attr_accessible :firstname, :lastname, :team_id
    belongs_to :team
 end

Immaginando che ci sia la seguente struttura
Team
id
name
1
team_1
2
team_2
Player
id
firstname
lastname
team_id
1
nome1
cognome1
1
2
nome2
cognome2
2
3
nome3
cognome3
2
4
nome4
cognome4
1
ActiveRecord genera, a seguito delle definizioni “has_many” e “belongs_to” dei metodi dinamici che rappresentano l’oggetto Team o le istanze di Player collegate
In particolare abbiamo a disposizione 2 metodi di instanza: “players” e “players=”. Vediamone l’uso
>> t=Team.first
Team Load (1.0ms)  SELECT "teams".* FROM "teams" LIMIT 1
#<Team id: 1, name: "team1", created_at: "2013-02-11 11:55:01", updated_at: "2013-02-11 11:55:01">
   
>> t.players
[#<Player id: 1, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-11 11:55:46", updated_at: "2013-02-11 11:55:46">, #<Player id: 2, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-11 11:55:57", updated_at: "2013-02-11 11:55:57">]
Player Load (1.0ms)  SELECT "players".* FROM "players" WHERE "players"."team_id" = 1

>> t.players=[]
SQL (1.0ms)  UPDATE "players" SET "team_id" = NULL WHERE "players"."team_id" = 1 AND "players"."id" IN (1, 2)
 []

Il metodo “players” risulta abbastanza naturale, ovvero restituisce un array con tutte le istanze di Player collegate al Team che stiamo esaminando. In base alla stessa convenzione, “players"=” setta l’array, e nel caso in cui gli si passi un array vuoto automaticamente elimina tutte le istanze precedentemente associate.
Pertanto se avessimo predisposto un array di player e lo avessimo passato al metodo palyers= avremmo ottenuto questo:
>> pl_array=[Player.new(:firstname=>"nome1",:lastname=>"cognome1"), Player.new(:firstname=>"nome2", :lastname=>"cognome2")]
[#<Player id: nil, lastname: "cognome1", firstname: "nome1", team_id: nil, created_at: nil, updated_at: nil>, #<Player id: nil, lastname: "cognome2", firstname: "nome2", team_id: nil, created_at: nil, updated_at: nil>]
   
>> t.players=pl_array
(0.0ms)  begin transaction
SQL (1.0ms)  UPDATE "players" SET "team_id" = NULL WHERE "players"."team_id" = 1 AND "players"."id" IN (4, 5)
SQL (0.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 08:06:48 UTC +00:00], ["firstname", "nome1"], ["lastname", "cognome1"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 08:06:48 UTC +00:00]]
SQL (0.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 08:06:48 UTC +00:00], ["firstname", "nome2"], ["lastname", "cognome2"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 08:06:48 UTC +00:00]]
(84.0ms)  commit transaction
[#<Player id: 6, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-12 08:06:48", updated_at: "2013-02-12 08:06:48">, #<Player id: 7, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-12 08:06:48", updated_at: "2013-02-12 08:06:48">]

Notare come sia stata prima annullata la presenza di eventuali record e poi inseriti in massa tutti i valori. Inoltre andando ad analizzare il contenuto di pl_array in cui registrato due nuove instanze di player notiamo che le istanze sono correttaemente associate a record del db:
>> pl_array
[#<Player id: 8, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">, #<Player id: 9, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">]

Ripetendo l’operazione t.players=pl_array non otteniamo nessun risultato in quanto le istanze sono già associate con il team.
Pertanto se nel controller implementassimo la creazione di un array avremmo ottenuto di salvare modello principale (team) e modelli collegati (players) con un unico form
La direttiva accepts_nested_attributes_for semplifica questo passaggio, generando un nuovo metodo, MODELNAME_attributes= , che nasconde tutti i meccanismi di definizione dell’array di risorse:
     class Team < ActiveRecord::Base
    attr_accessible :name, :players_attributes
    has_many :players
    accepts_nested_attributes_for :players
end
non abbiamo più la necessità di generare noi le istanze dei modelli collegati, ma semplicemente di passare un hash o un array di hash contenenti gli attributi da aggiornare:
>> pl_attributes = [{ :firstname => 'nome1', :lastname=>'cognome1'}, { :firstname => 'nome2', :lastname=>'cognome2'},{ :firstname => 'nome3', :lastname=>'cognome3'},{ :firstname => 'nome4', :lastname=>'cognome4'}]
  [{:firstname=>"nome1", :lastname=>"cognome1"}, {:firstname=>"nome2", :lastname=>"cognome2"}, {:firstname=>"nome3", :lastname=>"cognome3"}, {:firstname=>"nome4", :lastname=>"cognome4"}]
   
>> t.players
[#<Player id: 8, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">, #<Player id: 9, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">]
  
>> t.players_attributes=pl_attributes
[{:firstname=>"nome1", :lastname=>"cognome1"}, {:firstname=>"nome2", :lastname=>"cognome2"}, {:firstname=>"nome3", :lastname=>"cognome3"}, {:firstname=>"nome4", :lastname=>"cognome4"}]
   
>> t.save
(0.0ms)  begin transaction
SQL (3.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00], ["firstname", "nome1"], ["lastname", "cognome1"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00]]
SQL (1.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00], ["firstname", "nome2"], ["lastname", "cognome2"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00]]
SQL (0.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00], ["firstname", "nome3"], ["lastname", "cognome3"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00]]
SQL (1.0ms)  INSERT INTO "players" ("created_at", "firstname", "lastname", "team_id", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00], ["firstname", "nome4"], ["lastname", "cognome4"], ["team_id", 1], ["updated_at", Tue, 12 Feb 2013 09:14:35 UTC +00:00]]
(94.0ms)  commit transaction
  
>> t.players
[#<Player id: 8, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">, #<Player id: 9, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-12 08:46:39", updated_at: "2013-02-12 08:46:39">, #<Player id: 10, lastname: "cognome1", firstname: "nome1", team_id: 1, created_at: "2013-02-12 09:14:35", updated_at: "2013-02-12 09:14:35">, #<Player id: 11, lastname: "cognome2", firstname: "nome2", team_id: 1, created_at: "2013-02-12 09:14:35", updated_at: "2013-02-12 09:14:35">, #<Player id: 12, lastname: "cognome3", firstname: "nome3", team_id: 1, created_at: "2013-02-12 09:14:35", updated_at: "2013-02-12 09:14:35">, #<Player id: 13, lastname: "cognome4", firstname: "nome4", team_id: 1, created_at: "2013-02-12 09:14:35", updated_at: "2013-02-12 09:14:35">]

Possiamo notare le seguenti cose
-          Non è stato necessario creare i modelli degli oggetti, ma abbiamo dovuto definire solo un hash
-          L’assegnazione non ha scatenato, come prima, il salvataggio, ma è stato necessario salvare manualmente il modello principale
-          I dati passati al metodo non hanno sovrascritto quelli esistenti ma sono andati in aggiunta

La direttiva accepts_nested_attributes_for accetta anche delle ulteriori opzioni, che permettono di definire meglio il suo comportamento:
-          :allow_destroy – se impostato a true permette di utilizzare un attributo “_destroy”, valorizzato con true, per eliminare i record
-          :reject_if – permette di definire le condizioni in base alle quali scartare la generazione di un record
-          :limit – permette di definire un limite al numero di record processati

Analizzando meglio il modo in cui deve essere definito l’hash, e ricordando come lavora il metodo params, per ottenere una definizione @team.players_attributes=… ..  dobbiamo generare un passaggio di parametri come il seguente
team[players_attributes][1][firstname]=Nome1; team[players_attributes][1][lastname]=Cognome1;
team[players_attributes][2][firstname]=Nome2; team[players_attributes][2][lastname]=Cognome2;
team[players_attributes][3][firstname]=Nome3; team[players_attributes][3][lastname]=Cognome3; commit=Salva.....
nel caso di aggiornamento avremo
team[players_attributes][1][id]=1;team[players_attributes][1][firstname]=Luca; team[players_attributes][1][lastname]=Arcara; … commit=Salva.....
nel caso di cancellazione avremo
 team[players_attributes][1][id]=1;team[players_attributes][1][_destroy]=1;…;commit=Salva.....

il codice Rails che gestisce il rendering dei campi è il seguente:
<%= form_for(@team) do |f| %>
  <% if @team.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@team.errors.count, "error") %> prohibited this team from being saved:</h2>
      <ul>
        <% @team.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
    
  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>
  <%=f.fields_for :players do |p|%>
    <p>Giocatore:
      <%=p.text_field :firstname%>
      <%=p.text_field :lastname%>
      <%=p.check_box "_destroy"%> Elimina
    </p>
  <%end%>
  <p>Nuovo Giocatore:
     <%=text_field_tag "team[players_attributes][#{@team.players.count}][firstname]"%>
     <%=text_field_tag "team[players_attributes][#{@team.players.count}][lastname]"%>
  </p>
  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

NB: La sezione riguardante il nuovo giocatore può essere gestita anche in altro modo, ad esempio  creando un nuovo oggetto e verificando l’attributo new_record?, oppure utilizzando un po’ di javascript ed ajax per creare l’oggetto ed i corrispondenti tag HTML (un ottimo esempio lo trovate qui http://railscasts.com/episodes/196-nested-model-form-part-1?view=asciicast