Modeling Complex Relationships in Rails
-
22-07-2019 - |
Question
UPDATE
OK, I figured it out. I had to call the following for varieties:<%=h @user.varieties.find_by_product_id(product.id).name %>
Here's my two follow questions:
(1) Is this going to cause problems when editing/deleting records because I'm not calling the join model? I've seen some Ryan Bates videos where he stresses this point, but I'm having trouble trying to reference the join model here. In other words, should the above code by called through user_products
?
If I use the following code, which references the join table, I'm only able to get it to display the variety_id
from the join table (since there's no name column for varieties in the join table). I'm not sure how to make this code reference the variety_id
in the join table, but then go to the variety table to get the actual name of the variety from the "name" column.
<% @user.products.each do |product| %>
<% @user.user_products.find_by_product_id(product.id).variety_id %>
<% end %>
(2) Is this complex stuff properly placed in the view layer, or is there a better way to move it to the model or controller?
Thanks.
Original question below is now solved ...
I have the following models:
- users
- products
- varieties
- user_products
Here's the real world version of what I'm trying to do. Let's say User is a grocery store. Products are fruits, like apples. And varieties are types of apples, like fuji and mcintosh.
I need to create an app where:
A user can add many types of products to his page. The products can include multiple varieties, but the user is NOT required to include any varieties. For example, Topps Grocery Store can add apples to their page. If that's all they want to display, that needs to be ok. However, they can also add more detail by including the types of apples they carry, like fuji, mcintosh, etc. The variety can't just be a detailed product. In other words, I can't make each product be something like apple - fuji, apple - mcintosh. They need to be two separate models.
On the user's page (i.e. the "show" view), I need to be able to display both the product and the variety (if any). The system needs to understand that the varieties are connected to the particular product for this particular user.
Following the first answer I received, I revised my models as described in the answer below. Each variety belongs to one product, i.e. fuji belongs to only the apple product, which is a distinct id in the product table. And, a product has many varieties, i.e. the apple product might have 5 or 10 different varieties.
However, it gets more complicated because each user might have a different set of product/variety combinations. For example, Topps grocery store (user) might have apples (product) that are fuji and mcintosh (varieties). But, Publix grocery store (user) might have apples (product) that are red delicious and gala (varieties).
On the user page, I want to be able to call up each product the user carries and then display the varieties connected to each of those products if the user has chosen any varieties.
When I try the code listed below in the show view, I get the following error: undefined method `user_product' for #:
<% @user.products.each do |product| %>
<% @user.user_products.find_by_product_id(product.id).varieties %>
<% end %>
On the other hand, when I try the other option you gave me (listed below), the page loads and the sql query seems right in the log, but no varieties display on the page, which is weird because I triple checked and there are records in the database that should match the query. Details below...
<% @user.products.each do |product| %>
<% @user.varieties.find_by_product_id(product.id) %>
<% end %>
This code runs the following sql query:
User Load (0.7ms) SELECT * FROM "users" WHERE ("users"."id" = 2)
Variety Load (0.5ms) SELECT "varieties".* FROM "varieties" INNER JOIN "user_product" ON "varieties".id = "user_products".variety_id WHERE (("seasons".user_id = 2))
Rendering template within layouts/application
Rendering users/show
Product Load (0.7ms) SELECT "products".* FROM "products" INNER JOIN "user_product" ON "products".id = "user_products".product_id WHERE (("user_products".user_id = 2))
Variety Load (0.4ms) SELECT "varieties".* FROM "varieties" INNER JOIN "user_product" ON "varieties".id = "user_products".variety_id WHERE ("varieties"."product_id" = 1) AND (("user_products".user_id = 2)) LIMIT 1
Variety Load (0.2ms) SELECT "varieties".* FROM "varieties" INNER JOIN "user_products" ON "varieties".id = "user_products".variety_id WHERE ("varieties"."product_id" = 2) AND (("user_products".user_id = 2)) LIMIT 1
In this case above, the user that I'm looking at is user_id=2, he does have product_id=1 and product_id=2 in the database. And, in the user_products table, I do have a few records that list this user_id connected to each of these product_ids and associated with some variety_ids.
So it seems like I should be displaying some results on my show view, but I'm getting nothing.
Finally, when I try the following:
<% @user.products.each do |product| %>
<%=h @user.user_products.find_by_product_id(product.id) %>
<% end %>
It displays the following in my views for each record: #<User_product:0x4211bb0>
Solution
They way I see it is that you don't have a many to many relationship to the products. You have it to the product of that particulary variety (if any). So in order to keep track of it, I would use an extra model. That way you would be able to keep the relationship between products and varieties as well.
The models:
class User < ActiveRecord::Base
has_many :user_products, :dependent => :destroy
has_many :products, :through => :user_products
has_many :varieties, :through => :user_products
end
class UserProduct < ActiveRecord::Base
belongs_to :user
belongs_to :product
belongs_to :variety
end
class Product < ActiveRecord::Base
has_many :varieties
end
class Variety < ActiveRecord::Base
belongs_to :product
end
The controller:
class UserProductsController < ApplicationController
before_filter do
@user = User.find(params['user_id'])
end
def create
product = Product.find(params['product_id'])
variety = Variety.find(params['variety_id']) if params['variety_id']
@user_product = UserProduct.new
@user_product.user = user
@user_product.product = product
@user_product.variety = variety
@user_product.save
end
def destroy
# Either do something like this:
conditions = {:user_id => @user.id}
conditions[:product_id] = params[:product_id] if params[:product_id]
conditions[:variety_id] = params[:variety_id] if params[:variety_id]
UserProduct.destroy_all conditions
end
end
The views: If you are not interested in grouping the varieties into the different products and just put them as a list it is enough with this:
# users/show.html.erb
<%= render @user.user_products %>
# user_products/_user_product.html.erb
<%= h user_product.product.name %>
<%= h user_product.variety.name if user_product.variety %>
Otherwise something a bit more complicated have to be added. Some of it might be possible to put in the model and controller by using association proxies, but I can't help you there
# users/show
<% for product in @user.products do %>
<%= product.name %>
<%= render :partial => 'variety',
:collection => @user.varieties.find_by_product_id(product.id) %>
<% end %>
# users/_variety
<%= variety.name %>
Splitting it up in partials is of course not necessary (and in this example, maybe a bit ridiculous), but it helps in separating the different parts, especially if you want to add more things to display.
And to answer your questions:
- Since UserProduct is a join model, you don't really need to keep track of it's individual id. You have the User, the Product and the Variety (in those cases it exists). That's all you need, that combination is unique and thus, you can find the record for deletion. Also, a join model should never be edited (provided that you don't have more attributes in it than these fields), It is either create or delete, since all it do is keep the associations.
- Since it is basically a viewing thing it is best to place it in the views. You could of course use the controller or the model to collect all the products and the varieties beforehand by doing something like this:
Moving all of the logic to the controller and the model is probably not a good idea since they are not supposed to know (or care) how you want to display the data. So it is only a matter of preparing it enough.
@products_for_user = []
@user.products.each do |product|
collected_product = {:product => product,
:varieties => @user.varieties.find_by_product_id(product.id)}
@products_for_user << collected_product
end
OTHER TIPS
MikeH
You might just have a typo here, @user.user_product
should be @user.user_products
When I try the code listed below in the show view, I get the following error: undefined method `user_product' for #:
<% @user.products.each do |product| %> <% @user.user_product.find_by_product_id(product.id).varieties %> <% end %>
Also you might consider a hierarchical type system, where you just have products like "Fuji", "Macintosh", and "Apples".
"Fuji" and "Macintosh" would then have a column "parent_id" set to the id of "Apples".
Just a thought.