Websiteentwicklung: Ruby on Rails: En zu Em Beziehung

In diesem Kapitel werden wir weiter an der App tvtogether arbeiten. Zwischen users und episodes besteht eine n:m-Beziehung: eine Person kann mehrere Episoden gesehen haben, eine Episode wurde von mehreren Personen gesehen.

Railscast Dazu gibt es einen Railscast [1] HABTM Checkboxes (revised)

In einer relationalen Datenbank braucht man für die Darstellung einer n:m-Beziehung eine Zwischentabelle. Es es sinnvoll, aus dieser Zwischentabelle ein sogenanntes "Join Model" zu machen.

Join ModelBearbeiten

Die drei beteiligten Modelle haben dabei folgende Beziehungen:

 class A < ActiveRecord::Base
   has_many :ABs
   has_many :Bs, through: :ABs
 end
 class AB < ActiveRecord::Base
   belongs_to :A
   belongs_to :B
 end
 class B < ActiveRecord::Base
   has_many :ABs
   has_many :As, through: :ABs
 end


Wie soll das Join-Modell in unserem Projekt heißen? hat_gesehen? seen? oder denken wir schon weiter zu einem Rating: meinung? rating? Ich habe mich dann für rating entschieden:

 rails g model rating user_id:integer episode_id:integer rating:integer
 rake db:migrate

So sehen die Models aus:

 class User < ActiveRecord::Base
   has_many :ratings
   has_many :episodes, :through => :ratings
   ...
 end
 class Ratings < ActiveRecord::Base
   belongs_to :user
   belongs_to :episode
   validates :rating, :inclusion => { :in => [0,1,2,3,4,5] }, :allow_nil => true
 end
 class Episode < ActiveRecord::Base
   has_many :ratings
   has_many :users, :through => :ratings
   ...
 end


Join Model auf der ConsoleBearbeiten

Bevor wir mit dem Join-Model etwas programmieren, erforschen wir es interaktiv auf der Rails-Console:

 $ rails console
  Loading development environment (Rails 3.2.6)
  irb(main):001:0> u = User.first
    User Load (0.1ms)  SELECT "users".* FROM "users" LIMIT 1
    => #<User id: 1, name: "B", email: "b@gmail.com", password_digest: "$2a$10...">
  irb(main):002:0> e = Episode.last
    Episode Load (0.2ms)  SELECT "episodes".* FROM "episodes" ORDER BY "episodes"."id" DESC LIMIT 1
    => #<Episode id: 1658, tvshow_id: 9, epnum: 195, seasonnum: 10, title: "To The Boy in the Blue Knit Cap">
  irb(main):003:0> u.ratings
    Rating Load (0.1ms)  SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = 1
    => [#<Rating id: 1, user_id: 1, episode_id: 1658, rating: nil>]
  irb(main):004:0> u.episodes
    Episode Load (0.2ms)  SELECT "episodes".* FROM "episodes" INNER JOIN "ratings" ON "episodes"."id" = "ratings"."episode_id" WHERE "ratings"."user_id" = 1
  => [#<Episode id: 1658, tvshow_id: 9, epnum: 195, seasonnum: 10, title: "To The Boy in the Blue Knit Cap">]
  irb(main):005:0> u.episode_ids
  => [1658]

Die letzte Schreibweise, mit den ids, kann man auch für Zuweisungen verwenden:

 irb(main):007:0> u.episode_ids = [1650, 1651, 1652]
    Episode Load (0.4ms)  SELECT "episodes".* FROM "episodes" WHERE "episodes"."id" IN (1650, 1651, 1652)
    (0.1ms)  begin transaction
    SQL (0.3ms)  DELETE FROM "ratings" WHERE "ratings"."user_id" = 1 AND "ratings"."episode_id" = 1658
    SQL (15.1ms)  INSERT INTO "ratings" ("created_at", "episode_id", "rating", "updated_at", "user_id") VALUES (?, ?, ?, ?, ?)
    SQL (0.1ms)  INSERT INTO "ratings" ("created_at", "episode_id", "rating", "updated_at", "user_id") VALUES (?, ?, ?, ?, ?)
    SQL (0.1ms)  INSERT INTO "ratings" ("created_at", "episode_id", "rating", "updated_at", "user_id") VALUES (?, ?, ?, ?, ?)
    (0.8ms)  commit transaction
    => [1650, 1651, 1652]

Hier werden also eventuell vorhandene Ratings gelöscht, und dann für die drei neuen Episoden Ratings eingefügt.

Controller und ViewBearbeiten

Wir bauen nun eine edit_user View, die immer eine Liste aller Episoden anzeigt. Bei jeder Episode gibt es eine Checkbox die für die Rating-Beziehung steht.

Hier ein erster Entwurf der View:

 <h1>Fernsehen mit <%= @user.name %></h1>
  <%= form_for(@user) do |f| %>
    <div class="field">
      <%= f.label :name %><br />
      <%= f.text_field :name %>
    </div>
    <div class="field">
      <% Episode.all.each do |ep| %>
        <label>
        <%= check_box_tag "user[episode_ids][]", ep.id %>
        <%= ep.tvshow.name %>
        <%= ep.epnum %>
        <%= ep.title %>
        </label><br/>
      <% end %>
    </div>
    <div class="actions">
      <%= f.submit %>
    </div>
  <% end %>

Die params die dieses Formular liefert sehen z.B. so aus:

  "user"=>{
     "name"=>"Brigitte",
     "episode_ids"=>["1666", "1668", "1706", "1707", "1781"]
   }

So werden die Checkboxes im user_controller verarbietet: die episode_ids müssen gar nicht extra behandelt werden:

 def update
   @user = User.find(params[:id])
   if @user.update_attributes(params[:user])
     redirect_to @user, notice: "Erfolgreich gespeichert"
   else
     render :edit
   end
 end

Nur im user-Model müssen sie als attr_accessible eingetragen werden:

 class User < ActiveRecord::Base
   has_many :ratings
   has_many :episodes, :through => :ratings, :order => "episodes.tvshow_id"
   has_secure_password
   validates_presence_of :password, :on => :create
   attr_accessible :name, :email, :password, :password_confirmation, :episode_ids
 end

Was das Formular noch nicht kann: die Checkboxes sind nie bereits angeklickt, auch nicht wenn in der Datenbank bereits ein Rating exisitert.

Bei Formularfelder die über den from_for Tag erzeugt ist das immer automatisch gegeben: Sie spiegeln den Zustand des Models wieder.

Diesen Checkboxes müssen wir "von Hand" setzen:

 <%= check_box_tag "user[episode_ids][]", ep.id, @user.episode_ids.include?(ep.id) %>

Quellen

  1. Railscast HABTM Checkboxes (revised)