Topic: STI, Products & Formats

I'm in a bit of a pickle with my latest project, an online record label which sells both digital and hard copies of its music, along with promotional merchandise.

Now, I'd like to use the classic relationship of :products and :carts. The problem I am facing is that the products are so radically different that I'm not sure how to build my models (or DB for that matter).

I think I am on the right track but would love some feedback on my outline:

class Product
    has_and_belongs_to_many :cart_items
end

# t.columns for products
# :name, :price, :shipping_weight


Those 3 columns are really the only commonalities shared by all products. Now onto the variations:

class MusicFormat < Product
    has_and_belongs_to_many :releases
end

class DigitalFormat < Product
    has_and_belongs_to_many :mp3s
end

class Format < Product
    has_and_belongs_to_many :accessories
end


Basically what I would like is to create a select list of established formats. Music would have Vinyl LP, CD, etc.. MP3s would be available as singles, promos while accessories would have shirts, hats and whatnot.

Selecting a given format will automatically assign a price and shipping weight (if any) to a given item and save my client from headaches and errors.

I am leaning towards using one table for these format classes since they are so similar but I'm not sure .. should I go STI on this puppy using product as the parent class?
 
Moving on, let's take a look at the music products.

class RecordLabel
    has_many :releases
end

# t.columns for record_labels
# :logo, :name, :info, :email, :website

class Release
    belongs_to :record_label
    has_and_belongs_to_many :music_formats
    has_many :mp3s
end

# t.columns for releases
# :music_format_id, :record_label_id, :artist, :featuring, :cover_art,

class MP3
    belongs_to :release
    has_and_belongs_to_many :digital_formats
end

# t.columns for mp3s
# :digital_format_id, :release_id, :demo_file, :digital_download, :runtime


My biggest confusion here is in how to deal with releases, since they will all be available as individual MP3 files as well as MP3 packs...and sometimes they will also have vinyl or CD versions available. I imagine I can probably sort this out with model methods, but any advice on structure would be helpful too.

Now for the last bit:

class Accessory
  has_and_belongs_to_many :formats
end

# t.columns for accessories
# :format_id, :size, :color


Though I like to think of myself as an "advanced noob" I am pretty sure I butchered some ActiveRecord here. Feel free to provide suggestions or even yell at me if necessary wink

Re: STI, Products & Formats

Regarding STI I think you should put your products in as few tables as possible because of the following reasons:

1) It makes it easier for the products to share functionality (though you could always have them include a special Productable module that includes your custom methods).

2) It simplifies the database (which makes your application less brittle)

For things like distinguishing MP3 packs and single tracks I know of at least one site (I can't remember it off the top of my head) that had a structure like this:

class Album < Products
  has_many :tracks
end
class Track < Products
  belongs_to :album
end

And they had no problem selling both Tracks and whole Albums without confusion.

For putting this stuff into the cart I recommend a cart_items table that's polymorphically joined to your products of different classes and tables.

class CartItem < ActiveRecord::Base
  belongs_to :cartable, :polymorphic => true
  belongs_to :cart
end
# t.column for cart_items:
# :cartable_id, :cartable_type, :cart_id

And then each of your product classes should have
belongs_to :cartable

somewhere.

Does that simplify anything for you?

Re: STI, Products & Formats

The solution looks fairly complicated. I recommend taking a step back and try to simplify the problem. First list the requirements.

1. There are many different kinds of products
2. Each kind of product has drastically different attributes
3. A cart item needs to reference any kind of product
4. The products may share a few things (such as name and price)
5. Albums (releases) have many songs (mp3s) and both are products
6. Albums have different kinds of formats (CD, Vinyl, etc.)

I'm sure I missed some details, but this is a good starting point.

Point number 2 is key. Because these products are drastically different this tells me they should be separate models and tables. There are a few columns which are the same (name, price, shipping_weight), but this little bit of duplication is a trade for simplicity. In this case we do not have a Product model.

class Album < ActiveRecord::Base
  # :name, :price, :shipping_weight, :artist, etc.
  has_many :songs
end

class Song < ActiveRecord::Base
  # :name, :price, :shipping_weight, :album_id, :runtime, etc.
  belongs_to :album
end

class Accessory < ActiveRecord::Base
  # :name, :price, :shipping_weight, :color, etc.
end


On to point 3, how will you relate the cart item to the product? Polymorphic association is a great solution.

class Cart < ActiveRecord::Base
  # :created_at
  has_many :cart_items
end

class CartItem < ActiveRecord::Base
  # :product_id, :product_type, :cart_id, :quantity
  belongs_to :product, :polymoprhic => true
end


On to point 4. What if there becomes duplication in the code for the Album, Song and Acessory models? They are all products and share some columns so there will likely be duplication. If this is the case I recommend moving the duplication into a Product module (not model) and including the module in all of the product type models. I can provide code if you need it.

Lastly, how to handle formats. I'm not exactly sure how to implement this as I don't know enough details about your application. For example, does the price change when the user selects a different format? How about the shipping weight? If not, you can probably handle formats with a simple HABTM association.

class AlbumFormat < ActiveRecord::Base
  # :name
  has_and_belongs_to_many :albums
end

You can then add a format_id column to the CartItem model for selecting a format on the cart page. I can do into further detail if you need it.

Hope that helps.

Edit: Aww, Danger beat me to it. Looks like we had roughly the same solution though.

Last edited by ryanb (2007-02-27 14:06:44)

Railscasts - Free Ruby on Rails Screencasts

Re: STI, Products & Formats

Some excellent feedback here. I definitely want to address some key points you guys raised.

@ Danger

I agree that fewer tables is the best way to go. The main issue is that my models for the products are so radically different.  The end result would be a lot of empty fields. I'm no DB expert, but its seems logical for me to set up different tables for different types of content, even if they all fall under the umbrella of Products.

Your solution of a Cartable class is very helpful .Rather than just copy & paste, I am reading up on polymorphic joins in my AWDWR and really trying to wrap my head around it.

@ ryanb

Because these products are drastically different this tells me they should be separate models and tables. In this case we do not have a Product model.

This is exactly what I am struggling with. If there is no Product model, then how do I draw a one-time association to the cart? You provide some insight in your suggestion of a module, but I really need to research this more as I am totally unfamiliar with them.

Something curious happening in your code example here:

class Cart < ActiveRecord::Base
  # :created_at
  has_many :cart_items
end

class CartItem < ActiveRecord::Base
  # :product_id, :product_type, :cart_id, :quantity
  belongs_to :product, :polymoprhic => true
end


Should the class CartItem also include belongs_to :cart or is this intentionally omitted?

Anyways. addressing your last point, you guessed correctly the first time. Selecting a format will change both the price and shipping weight of a product. Taking a stab in the dark, I am guessing that perhaps has_many :through would be ideal for this though I'm not 100% clear on how to implement.

I realize its my job to figure a lot of this stuff out, but your hints are definitely more than helpful, so I won't exactly be upset if you guys post more code smile

Edit: Aww, Danger beat me to it. Looks like we had roughly the same solution though.

I say the more the merrier. You both have the same ideas, but each of you touched on some additional nuggets I was unaware of. It's also comforting to see similar approaches within such a flexible framework.

Post away!

Last edited by pimpmaster (2007-02-28 13:31:40)

Re: STI, Products & Formats

pimpmaster wrote:

This is exactly what I am struggling with. If there is no Product model, then how do I draw a one-time association to the cart? You provide some insight in your suggestion of a module, but I really need to research this more as I am totally unfamiliar with them.

Adding a Product module will not help in regards to how it is associated to the Cart and other models. It is just a way to remove duplication of code if you find duplication cropping up between the different product models.

pimpmaster wrote:

Should the class CartItem also include belongs_to :cart or is this intentionally omitted?

My bad, I forgot it:

class CartItem < ActiveRecord::Base
  # :product_id, :product_type, :cart_id, :quantity
  belongs_to :cart
  belongs_to :product, :polymorphic => true
end

pimpmaster wrote:

Anyways. addressing your last point, you guessed correctly the first time. Selecting a format will change both the price and shipping weight of a product. Taking a stab in the dark, I am guessing that perhaps has_many :through would be ideal for this though I'm not 100% clear on how to implement.

This does make things a little more complex. A couple more questions.

1. I'm guessing the pricing of the format isn't always consistent. That is, a Vinyl of one album will be a different price than a vinyl of another. What about shipping weight? Is there any rules/patterns that can be used to calculate the prices for the different formats? (Such as always add $2.00 for this format).

2. How do you want the interface to behave? It all comes down to this. Do you want the user to choose the format before or after they add the product to their cart? Any other details you can provide will help.

Railscasts - Free Ruby on Rails Screencasts

Re: STI, Products & Formats

1. Actually, the pricing on music products will be quite consistent. To illustrate my point, this would be the dropdown menu visible while creating a new release.

Choose Format...
    - Vinyl Single EP (sets $8.99, 0.5 lbs)
    - Vinyl Double LP (sets $15.99, 0.9lbs)
    - CD Single (sets $7.99, 0.3 lbs)
    - CD Double (sets $13.99, 0.5 lbs)
    - MP3-only Release (sum all mp3s and subtract 15% off price)

The price of individual MP3s is consistent system-wide and will be configured from store settings panel. (the prices for other formats would be set here as well)

Selling accessories will work differently. The admin will set that price manually and just have a list of items that deduce shipping weight:

Choose Product...
    - T-shirt - 0.5 lbs
    - Hat - 0.2 lbs
    - Slipmat - 0.1lbs

2. I have already built the interface, but my client won't allow me to post publicly so I have emailed you the link. It should give you a clear idea of how we are organizing things.

Re: STI, Products & Formats

I think we're really close. The only thing I'm unsure about is the specifics regarding formats. You will most likely need some kind of has_many :through association with formats (instead of HABTM) to give you enough flexibility.

class Album < ActiveRecord::Base
  # - name
  has_many :album_formats
  has_many :formats, :through => :album_formats
end

class AlbumFormat < ActiveRecord::Base
  # - album_id
  # - format_id
  # - custom_price (in case you need to override format price)
  # - custom_shipping_weight
  belongs_to :album
  belongs_to :format
 
  def price
    custom_price || format.price
  end
 
  def shipping_weight
    custom_shipping_weight || format.shipping_weight
  end
 
  def name # may not need this
    "#{album.name} #{format.name}"
  end
end

class Format < ActiveRecord::Base
  # - name (Vinyl Single EP, CD Single, etc.)
  # - price
  # - shipping_weight
  has_many :album_formats
  has_many :albums, :through => :album_formats
end


Now for the question: how should this relate to the cart? As you know the cart item is using polymorphic association so basically any model can act as a product. This means you have two options: should "Album" be the product or should "AlbumFormat" be the product?

Storing the Album as the product may be a good idea because it allows you to easily change the format after the product has been added to the cart (in say, a select menu). The downside is the cart item will have to keep track of the product_id and format_id separately and juggle between the two to determine the price.

If you don't need to change the format after adding it to the cart then I would go with using AlbumFormat as the product. In this case an Album wouldn't act as the product at all and this simplifies things a little.

Does that make sense? Do you have any questions on how this all fits together?

Railscasts - Free Ruby on Rails Screencasts

Re: STI, Products & Formats

Wow, nice one Ryan. It's still not a perfect fit yet but you are definitely inspiring me with simplicity and I am finally understanding just how has_many :through works.

Looking at your example, I see how I can simplify even further. Since custom prices will only be applied to merchandise (and not musical products) I can whittle those custom prices/shipping out of album_formats and use them in product_formats instead. Sweetness!

More questions in my head though:

1. I am still unclear about the selection of formats. For example, say I am creating a new album, I dont need to see options for t-shirts or hats. To illuminate my conundrum, here is the ERB code that I use to create a selection list.

<%= f.select :format_id, [['Choose Format', '']] + @formats.collect {|ft| [ft.name, ft.id]} %>

The above code returns a list of all formats. Makes me wonder if the name column should live inside the join table instead of formats table. Hmm...

2. In your class methods you use the || operator.. In my complete and utter  noobness, I wonder why you didnt use the ||= instead. I never could grasp the difference between the two.

3. Looking at your previous example of Cart class, I am not sure how to draw the relationship to my products. Since the customer wont be able to change formats in their cart, should I use format_id instead of product_id?

Last edited by pimpmaster (2007-03-01 12:05:16)

Re: STI, Products & Formats

pimpmaster wrote:

1. I am still unclear about the selection of formats. For example, say I am creating a new album, I dont need to see options for t-shirts or hats. To illuminate my conundrum, here is the ERB code that I use to create a selection list.

<%= f.select :format_id, [['Choose Format', '']] + @formats.collect {|ft| [ft.name, ft.id]} %>

The above code returns a list of all formats. Makes me wonder if the name column should live inside the join table instead of formats table. Hmm...

Are you selecting the format for a given release/album? In that case you would loop through the "album_formats" instead of just "formats". For example:

<%= f.select :format_id, [['Choose Format', '']] + @album.album_formats.collect {|ft| [ft.name, ft.id]} %>

Formats are definitely a tricky thing - by far the most difficult part of this design. I think the reason being is there are so many ways to do it. It's hard to say the best approach without knowing the problem domain fully.

pimpmaster wrote:

2. In your class methods you use the || operator.. In my complete and utter  noobness, I wonder why you didnt use the ||= instead. I never could grasp the difference between the two.

The key difference is "||=" will set the variable on the left side of the operator if it returns nil/false (the right side is executed). Without the equal sign no variable gets set, it just executes one or the other.

The "||=" operator is often used for caching (if the statement to the right is expensive). But in this case I don't think you will get any performance gain from it.

pimpmaster wrote:

3. Looking at your previous example of Cart class, I am not sure how to draw the relationship to my products. Since the customer wont be able to change formats in their cart, should I use format_id instead of product_id?

If the customer won't be able to change the format in their cart then I recommend not having a format_id column in the CartItem join model.

class Cart < ActiveRecord::Base
  # :created_at
  has_many :cart_items
end

class CartItem < ActiveRecord::Base
  # :product_id, :product_type, :cart_id, :quantity
  belongs_to :product, :polymorphic => true
  belongs_to :cart
end


You would set the product_id to the AlbumFormat's id so the format is basically already specified.

Railscasts - Free Ruby on Rails Screencasts

Re: STI, Products & Formats

Thanks a heap Ryan (you too Dangerman)

I definitely have enough coding artillery to be dangerous now wink

Will let u know how it pans out

Re: STI, Products & Formats

Diving back into this project now and rethinking the DB relationships.

To begin with, albums will really only have one format. So I wonder if its a better solution for a format to have many albums (and avoid the :through association entirely)

Gonna poke around with this a bit more.