Ruby on Rails
HowToUseActsAsListWithHasAndBelongsToMany

Update: There’s a plugin to do this. See: BetterHabtmList

While at first, this task may seem relatively difficult, involving all sorts of hacks — or an update to Rails itself — it’s relatively easy to do.

On the other hand, this is not a pure solution. It will allow you to use the equivelent of a habtm relationship in the current rails framework. But, you will have to use some minor workarounds. On the other, other hand, it just goes to show how extremely amazing and flexible rails is as a framework.

The standard many-to-many relationship

Normally, in a habtm relationship, you create two models based on two related many-to-many objects. So, for instance, suppose I wanted to relate promotions to products. A single promotion has many products, and a product can belong to many promotions. To represent this relationship in rails, I’d simply create two models based on each object and relate them with the has_and_belongs_to_many declaration.

In the underlying database, you’d create 3 tables: promotions, products, and products_promotions. The third table would act as a bridge table to store linking IDs between the other two.

Model the bridge, not the habtm

So, your database now represents a habtm relationship, but that relationship doesn’t really represent a list. A list is really a plain old one-to-many, or parent-child, relationship. The key then, is to create a model structure that reflects the list relationship, even if your database can’t. Essentially, by creating a model based on the habtm bridge table itself, you can create the one-to-many relationship you need to use acts_as_list.

A habtm list in 6 easy steps

Step 1. In your database “bridge” table (e.g. products_promotions), add the required integer “position” field. In the earlier example, I only wanted to sort products within a given promotion, so the bridge table is the best place to store the sorting information.

Step 2. Generate a new ActiveRecord model based on the bridge table. I might call mine PromoProduct

ruby script/generate model PromoProduct

Step 3. Normally, rails would look for a promo_products table to fill this class with data, so we need to tell it to use the real bridge table. In the model definition, add a set_table_name declaration, like so:


class PromoProduct < ActiveRecord::Base
set_table_name “products_promotions”
acts_as_list :scope => :promotion_id
belongs_to :promotion
belongs_to :product
end

Notice that you also indicate that a single bridge model object belongs to both parent objects. And, of course, I assigned the list relationship to the parent foreign key field, as well.

Step 4. Assign the parent model relationship. For my lists I only wanted to display products for a promotion, so I modified just the Promotion parent class, and added:

class Promotion< ActiveRecord::Base
     has_many :promo_products,
              :order => :position

As it stands, while good on paper, this code still won’t work. First and foremost, when you programmatically create child objects (PromoProduct) so you can assign them to a parent, rails is going to look for an “id” field when it creates a new row. Since this model is tied to a bridge table, the id field doesn’t exist.

So, make sure that it does.

Step 5: Create a dummy id field in the bridge table, but don’t name it id!. Instead name it something else, and then use the set_primary_key declaration to assign it as primary key for ActiveRecord:


class PromoProduct < ActiveRecord::Base
set_table_name “products_promotions”
set_primary_key “temp_id”
acts_as_list :scope => :promotion_id
belongs_to :promotion
belongs_to :product

Step 6: Create a method to assign products to each PromoProduct instance.

Since the relationship between the parent object and the child object doesn’t include the real habtm linked object, you’ll need to provide a way to link this secondary object to the bridge class. I took a page from Agile Web Development with Rails, and added the following method to my PromoProduct class:

def self.for_product(product)
   item = self.new
   item.product = product
   item
end

Code samples using the bridge model

Here’s a sample of code that utilizes the bridge model:

Adding linked items:


prod = Product.find(params[:id])
if prod
@product_list << PromoProduct.for_product(prod)
end

You might use this code when you need linked item information. So, here, if we wanted to grab a product’s title, we’d use something like

   @promoproduct.product.title

Assigning linked items:
If you need to assign a subordinate object to it’s parent via the bridge, I’d suggest using the create method, like so:

    @promotion.promo_products.create({:product_id => temp_id,
      :price=> temp_price,
      :term => temp_term})

Notice that in this case, I manually assigned the product_id, foreign key value in the bridge table. There may be ways to automatically assign a product, but I didn’t try every alternative.

Clearing linked relationships
In my experiments, I was unable to use the clear() method to clear relationship rows between a parent and it’s bridge children. Instead, I used the delete_all() method, like this:

PromoProduct.delete_all(["promotion_id = ?", @promotion.id])

Notice that I had to supply the conditions, since normally rails uses the primary key to find the right record. In this case, though, the primary key holds a dummy value—so using it isn’t an option.

Again, perhaps with a little more work, this technique could be modified to work with clear() and the default ID field.