Neu: Das englische Ruby on Rails 4.0 Buch.

4.9. Many-to-Many – n:n-Verknüpfung

Bereits in ??? wurde ein Many-to-Many-Beispiel besprochen. Dieser Abschnitt geht intensiver auf die gleiche Idee ein. Wenn Sie ??? verstanden haben, können Sie auch zum nächsten Abschnitt springen.
Bis jetzt haben wir immer eine Datenbank direkt mit einer anderen Datenbank verknüpft. Bei Many-to-Many werden wir zwei Datenbanken mithilfe einer dritten Datenbank verknüpfen. Als Beispiel für eine solche Verknüpfung nehmen wir eine Bestellung in einem Online-Shop. In einem solchen Shop-System kann ein Produkt (Product) in mehreren Bestellungen (Order) auftauchen und gleichzeitig kann eine Bestellung mehrere Produkte enthalten. Das nennt sich dann Many-to-Many. Am besten stellen wir das mal mit Code nach.

Vorbereitung

Die Shop-Applikation erstellen:
MacBook:~ xyz$ rails new shop
[...]
MacBook:~ xyz$ cd shop
MacBook:shop xyz$
Ein Model für Produkte:
MacBook:shop xyz$ rails generate model product name 'price:decimal{7,2}'
      invoke  active_record
      create    db/migrate/20120507135639_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/unit/product_test.rb
      create      test/fixtures/products.yml
MacBook:shop xyz$
Ein Model für eine Bestellung:
MacBook:shop xyz$ rails generate model order delivery_address
      invoke  active_record
      create    db/migrate/20120507135744_create_orders.rb
      create    app/models/order.rb
      invoke    test_unit
      create      test/unit/order_test.rb
      create      test/fixtures/orders.yml
MacBook:shop xyz$
Und noch ein Model für die einzelnen Positionen einer Bestellung:
MacBook:shop xyz$ rails generate model line_item order_id:integer product_id:integer quantity:integer
      invoke  active_record
      create    db/migrate/20120507141437_create_line_items.rb
      create    app/models/line_item.rb
      invoke    test_unit
      create      test/unit/line_item_test.rb
      create      test/fixtures/line_items.yml
MacBook:shop xyz$
Zum Schluss die Datenbank anlegen:
MacBook:shop xyz$ rake db:migrate
[...]
MacBook:shop xyz$ 

Die Verknüpfung

Eine Bestellung (Order) besteht aus einer oder mehreren Positionen (LineItem). Ein solcher LineItem besteht aus der order_id, einer product_id und der Bestellmenge (quantity). Das einzelne Produkt wird in der Produktdatenbank (Product) definiert.

Anmerkung

Alle Modelle sind hier in einer minimalen Art und Weise generiert worden. Es geht ja nicht um ein echtes Shop-System, sondern um das Verständnis.
Die Verknüpfung der Modelle geschieht wie immer im app/models-Verzeichnis. Als Erstes in der Datei app/models/order.rb:
class Order < ActiveRecord::Base
  attr_accessible :delivery_address

  has_many :line_items
  has_many :products, :through => :line_items
end
Und dann im Gegenstück der Datei app/models/product.rb:
class Product < ActiveRecord::Base
  attr_accessible :name, :price

  has_many :line_items
  has_many :orders, :through => :line_items
end
Zum Schluss noch die Datei app/models/line_item.rb:
class LineItem < ActiveRecord::Base
  attr_accessible :order_id, :product_id, :quantitiy

  belongs_to :order
  belongs_to :product
end

Die Verknüpfung funktioniert logischerweise wie has_many

Da wir die Verknüpfungen mit has_many realisieren, werden Ihnen die meisten Sachen schon aus Abschnitt 4.8, „has_many – 1:n-Verknüpfung“ bekannt vorkommen. Ich werde hier exemplarisch ein paar Beispiele besprechen. Alle Details finden Sie in Abschnitt 4.8, „has_many – 1:n-Verknüpfung“.
Wir füllen als Erstes unsere Produktdatenbank mit folgenden Werten:
MacBook:shop xyz$ rails console
Loading development environment (Rails 3.2.3)
1.9.3p194 :001 > milch = Product.create(:name => 'Milch (1 Liter)', :price => 0.45)
   (0.1ms)  begin transaction
  SQL (6.8ms)  INSERT INTO "products" ("created_at", "name", "price", "updated_at") VALUES (?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:03:43 UTC +00:00], ["name", "Milch (1 Liter)"], ["price", #<BigDecimal:7fbbd4613db0,'0.45E0',9(45)>], ["updated_at", Mon, 07 May 2012 14:03:43 UTC +00:00]]
   (1.1ms)  commit transaction
 => #<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7fbbd3fe71d0,'0.45E0',9(45)>, created_at: "2012-05-07 14:03:43", updated_at: "2012-05-07 14:03:43"> 
1.9.3p194 :002 > butter = Product.create(:name => 'Butter (250 gr)', :price => 0.75)
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "products" ("created_at", "name", "price", "updated_at") VALUES (?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:04:45 UTC +00:00], ["name", "Butter (250 gr)"], ["price", #<BigDecimal:7fbbd461d8d8,'0.75E0',9(45)>], ["updated_at", Mon, 07 May 2012 14:04:45 UTC +00:00]]
   (2.5ms)  commit transaction
 => #<Product id: 2, name: "Butter (250 gr)", price: #<BigDecimal:7fbbd4628b70,'0.75E0',9(45)>, created_at: "2012-05-07 14:04:45", updated_at: "2012-05-07 14:04:45"> 
1.9.3p194 :003 > mehl = Product.create(:name => 'Mehl (1 kg)', :price => 0.45)
   (0.1ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "products" ("created_at", "name", "price", "updated_at") VALUES (?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:05:26 UTC +00:00], ["name", "Mehl (1 kg)"], ["price", #<BigDecimal:7fbbd3ef3260,'0.45E0',9(45)>], ["updated_at", Mon, 07 May 2012 14:05:26 UTC +00:00]]
   (5.7ms)  commit transaction
 => #<Product id: 3, name: "Mehl (1 kg)", price: #<BigDecimal:7fbbd3ef6438,'0.45E0',9(45)>, created_at: "2012-05-07 14:05:26", updated_at: "2012-05-07 14:05:26"> 
1.9.3p194 :004 > 
Jetzt legen wir ein neues Order-Objekt bestellung an:
1.9.3p194 :004 > bestellung = Order.new(:delivery_address => 'Bahnhofstr. 123, 12345 Beispielhausen')
 => #<Order id: nil, delivery_address: "Bahnhofstr. 123, 12345 Beispielhausen", created_at: nil, updated_at: nil> 
1.9.3p194 :005 >
Logischerweise enthält diese neue Bestellung noch keine Produkte:
1.9.3p194 :005 > bestellung.products
 => [] 
1.9.3p194 :006 >
Es gibt – wie so oft – verschiedene Arten, wie man Produkte zur Bestellung hinzufügt. Der einfachste Weg: Da die Produkte als Array eingebunden werden, kann man diese auch einfach als Elemente eines Arrays einfügen:
1.9.3p194 :006 > bestellung.products << milch
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7fbbd3f3b5d8,'0.45E0',9(45)>, created_at: "2012-05-07 14:03:43", updated_at: "2012-05-07 14:03:43">] 
1.9.3p194 :007 > bestellung.products
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7fbbd475c7d0,'0.45E0',9(45)>, created_at: "2012-05-07 14:03:43", updated_at: "2012-05-07 14:03:43">] 
1.9.3p194 :008 > 
Wenn der Käufer jetzt aber nicht einen Liter Milch, sondern drei Liter Milch kaufen möchte, so müssen wir dies im LineItem (also im verknüpfenden Element) eintragen. Dummerweise gibt es aber noch gar keinen LineItem zu dieser Bestellung:
1.9.3p194 :008 > bestellung.line_items
 => [] 
1.9.3p194 :009 >
Warum? Weil wir das Objekt noch nicht in der Datenbank gespeichert haben. Wenn wir dies mit save machen, können wir auch die Menge in den line_items verändern:
1.9.3p194 :009 > bestellung.save
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "orders" ("created_at", "delivery_address", "updated_at") VALUES (?, ?, ?)  [["created_at", Mon, 07 May 2012 14:21:59 UTC +00:00], ["delivery_address", "Bahnhofstr. 123, 12345 Beispielhausen"], ["updated_at", Mon, 07 May 2012 14:21:59 UTC +00:00]]
  SQL (0.5ms)  INSERT INTO "line_items" ("created_at", "order_id", "product_id", "quantity", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:21:59 UTC +00:00], ["order_id", 1], ["product_id", 1], ["quantity", nil], ["updated_at", Mon, 07 May 2012 14:21:59 UTC +00:00]]
   (3.6ms)  commit transaction
 => true 
1.9.3p194 :010 > bestellung.line_items
 => [#<LineItem id: 1, order_id: 1, product_id: 1, quantity: nil, created_at: "2012-05-07 14:21:59", updated_at: "2012-05-07 14:21:59">] 
1.9.3p194 :011 > bestellung.line_items.first.update_attributes(:quantity => 3)
   (0.1ms)  begin transaction
   (0.4ms)  UPDATE "line_items" SET "quantity" = 3, "updated_at" = '2012-05-07 14:22:22.480585' WHERE "line_items"."id" = 1
   (3.4ms)  commit transaction
 => true 
1.9.3p194 :012 > LineItem.first
  LineItem Load (0.3ms)  SELECT "line_items".* FROM "line_items" LIMIT 1
 => #<LineItem id: 1, order_id: 1, product_id: 1, quantity: 3, created_at: "2012-05-07 14:21:59", updated_at: "2012-05-07 14:22:22"> 
1.9.3p194 :013 > 
Alternativ können wir aber auch direkt zweimal Butter kaufen, indem wir ein LineItem hinzufügen:
1.9.3p194 :015 > bestellung.line_items.create(:product_id => butter.id, :quantity => 2)
   (0.1ms)  begin transaction
  SQL (0.7ms)  INSERT INTO "line_items" ("created_at", "order_id", "product_id", "quantity", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:24:28 UTC +00:00], ["order_id", 1], ["product_id", 2], ["quantity", 2], ["updated_at", Mon, 07 May 2012 14:24:28 UTC +00:00]]
   (3.5ms)  commit transaction
 => #<LineItem id: 2, order_id: 1, product_id: 2, quantity: 2, created_at: "2012-05-07 14:24:28", updated_at: "2012-05-07 14:24:28"> 
1.9.3p194 :016 > 

Warnung

Zu diesem Zeitpunkt dürfen Sie nicht den Fehler machen und denken, dass das Objekt bestellung bereits über die bestellte Butter verfügt. Schauen wir uns mal die products an:
1.9.3p194 :016 > bestellung.products
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7f820bf6b190,'0.45E0',9(45)>, created_at: "2012-05-07 14:20:05", updated_at: "2012-05-07 14:20:05">] 
1.9.3p194 :017 >
Aber in der Datenbank ist es bereits richtig:
1.9.3p194 :017 > Order.first.products
  Order Load (0.3ms)  SELECT "orders".* FROM "orders" LIMIT 1
  Product Load (0.3ms)  SELECT "products".* FROM "products" INNER JOIN "line_items" ON "products"."id" = "line_items"."product_id" WHERE "line_items"."order_id" = 1
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7f820ba849d8,'0.45E0',9(45)>, created_at: "2012-05-07 14:20:05", updated_at: "2012-05-07 14:20:05">, #<Product id: 2, name: "Butter (250 gr)", price: #<BigDecimal:7f820ba83a60,'0.75E0',9(45)>, created_at: "2012-05-07 14:20:31", updated_at: "2012-05-07 14:20:31">] 
1.9.3p194 :018 > 
Sie müssten in diesem konkreten Fall das Objekt mit der Methode reload neu aus der Datenbank laden:
1.9.3p194 :020 > bestellung.reload
  Order Load (0.3ms)  SELECT "orders".* FROM "orders" WHERE "orders"."id" = ? LIMIT 1  [["id", 1]]
 => #<Order id: 1, delivery_address: "Bahnhofstr. 123, 12345 Beispielhausen", created_at: "2012-05-07 14:21:59", updated_at: "2012-05-07 14:21:59"> 
1.9.3p194 :021 > bestellung.products
  Product Load (0.3ms)  SELECT "products".* FROM "products" INNER JOIN "line_items" ON "products"."id" = "line_items"."product_id" WHERE "line_items"."order_id" = 1
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7f820bd4a410,'0.45E0',9(45)>, created_at: "2012-05-07 14:20:05", updated_at: "2012-05-07 14:20:05">, #<Product id: 2, name: "Butter (250 gr)", price: #<BigDecimal:7f820bd51468,'0.75E0',9(45)>, created_at: "2012-05-07 14:20:31", updated_at: "2012-05-07 14:20:31">] 
1.9.3p194 :022 >
Geben wir eine zweite Bestellung mit allen verfügbaren Produkten ins System:
1.9.3p194 :022 > bestellung2 = Order.create(:delivery_address => 'Teststr. 2')
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "orders" ("created_at", "delivery_address", "updated_at") VALUES (?, ?, ?)  [["created_at", Mon, 07 May 2012 14:27:52 UTC +00:00], ["delivery_address", "Teststr. 2"], ["updated_at", Mon, 07 May 2012 14:27:52 UTC +00:00]]
   (3.5ms)  commit transaction
 => #<Order id: 2, delivery_address: "Teststr. 2", created_at: "2012-05-07 14:27:52", updated_at: "2012-05-07 14:27:52"> 
1.9.3p194 :023 > bestellung2.products << Product.all
  Product Load (0.3ms)  SELECT "products".* FROM "products" 
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "line_items" ("created_at", "order_id", "product_id", "quantity", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:28:01 UTC +00:00], ["order_id", 2], ["product_id", 1], ["quantity", nil], ["updated_at", Mon, 07 May 2012 14:28:01 UTC +00:00]]
  SQL (0.3ms)  INSERT INTO "line_items" ("created_at", "order_id", "product_id", "quantity", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:28:01 UTC +00:00], ["order_id", 2], ["product_id", 2], ["quantity", nil], ["updated_at", Mon, 07 May 2012 14:28:01 UTC +00:00]]
  SQL (0.2ms)  INSERT INTO "line_items" ("created_at", "order_id", "product_id", "quantity", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Mon, 07 May 2012 14:28:01 UTC +00:00], ["order_id", 2], ["product_id", 3], ["quantity", nil], ["updated_at", Mon, 07 May 2012 14:28:01 UTC +00:00]]
   (2.5ms)  commit transaction
  Product Load (0.2ms)  SELECT "products".* FROM "products" INNER JOIN "line_items" ON "products"."id" = "line_items"."product_id" WHERE "line_items"."order_id" = 2
 => [#<Product id: 1, name: "Milch (1 Liter)", price: #<BigDecimal:7f820a1d6760,'0.45E0',9(45)>, created_at: "2012-05-07 14:20:05", updated_at: "2012-05-07 14:20:05">, #<Product id: 2, name: "Butter (250 gr)", price: #<BigDecimal:7f820a31ad10,'0.75E0',9(45)>, created_at: "2012-05-07 14:20:31", updated_at: "2012-05-07 14:20:31">, #<Product id: 3, name: "Mehl (1 kg)", price: #<BigDecimal:7f820a408e70,'0.45E0',9(45)>, created_at: "2012-05-07 14:20:48", updated_at: "2012-05-07 14:20:48">] 
1.9.3p194 :024 > bestellung2.save
   (0.1ms)  begin transaction
   (0.1ms)  commit transaction
 => true 
1.9.3p194 :025 >
Jetzt können wir die andere Seite der Many-to-Many-Verbindung ausprobieren. Suchen wir mal nach allen Bestellungen, in denen das erste Produkt enthalten ist:
1.9.3p194 :027 > Product.first.orders
  Product Load (0.1ms)  SELECT "products".* FROM "products" LIMIT 1
  Order Load (0.2ms)  SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "orders"."id" = "line_items"."order_id" WHERE "line_items"."product_id" = 1
 => [#<Order id: 1, delivery_address: "Bahnhofstr. 123, 12345 Beispielhausen", created_at: "2012-05-07 14:21:59", updated_at: "2012-05-07 14:21:59">, #<Order id: 2, delivery_address: "Teststr. 2", created_at: "2012-05-07 14:27:52", updated_at: "2012-05-07 14:27:52">] 
1.9.3p194 :028 > 
Natürlich können wir auch mit einem joins (siehe „joins“) arbeiten und alle Bestellungen, die das Produkt "Milch (1 Liter)" enthalten suchen:
1.9.3p194 :028 > Order.joins(:products).where(:products => {:name => 'Milch (1 Liter)'})
  Order Load (0.3ms)  SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id" INNER JOIN "products" ON "products"."id" = "line_items"."product_id" WHERE "products"."name" = 'Milch (1 Liter)'
 => [#<Order id: 1, delivery_address: "Bahnhofstr. 123, 12345 Beispielhausen", created_at: "2012-05-07 14:21:59", updated_at: "2012-05-07 14:21:59">, #<Order id: 2, delivery_address: "Teststr. 2", created_at: "2012-05-07 14:27:52", updated_at: "2012-05-07 14:27:52">] 
1.9.3p194 :029 > 
Mehr Informationen zu has_many ... :through finden Sie unter ???.