Creating an Extensible User Favorites System in Rails
a comprehensive guide to laying down the foundation for and implementing a powerful, custom favorites system
3 comments no linksthe problem
So at some point last night I got over some hump in the development of do{block}, the hump at which you come out of that sketchy alpha/beta phase, where you're no longer just trying to implement basic functionality like user authentication and session handling and so forth, and I found myself in a position to begin adding non-essential but useful features to the site.
There were a couple of things floating around in my head, as you'll see here, like a pingback system (which I'll certainly implement soon, and is easy enough with a plugin), and a messaging system, but ultimately I decided that at this point in the development cycle the most important thing is to actively connect users with the site and keep everyone in the loop.
planning
So I start batting around this idea of users being able to keep track of certain topics, so they can still receive updates from the site but not feel like they're burdened by anything in terms of their inbox being flooded with notices and so forth. And my first inclination, following the principle of Occam's Razor, is to do the simplest thing possible, which is to create a bi-directional has_and_belongs_to_many relationship between the User model and the Topic model. Something like this:
#!/app/models/user.rb has_and_belongs_to_many :categories
And:
#!/app/models/category.rb has_and_belongs_to_many :users
This would require a migration that would create an inner join table for the two models, like this:
user@domain$ cd my_app user@domain$ ./script/generate migration AddTopicsUsersTable exists db/migrate create db/migrate/20090812050609_add_topics_users_table.rb
Then you'd open that migration file and edit it to look something like this:
#!/db/migrate/XXXXXXXXXXXXXX_add_topics_users_table.rb class AddTopicsUsersTable < ActiveRecord::Migration def self.up create_table :topics_users, :id => false do |t| t.references :topic t.references :user end end def self.down drop_table :topics_users end end
(if you are inclined to use this method for whatever reason, note that you must pass the :id => false option into the create_table method because the table performs an inner_join, and will actually produce problems by having a primary key of its own)
Then you'd create that topics_users table with rake db:migrate and all of the sudden you can add and delete relationships between objects of those model classes quite simply, like this:
user@domain$ ./script/console >> u = User.find_by_name("jose") >> t = Topic.find_by_name("humons") >> u.topics => [] >> u.topics << t => #<Topic id: 4, name: "humons" ...> >> u.save >> t.users => #<User id: 2, name: "jose" ...>
So that's really great, and there are a whole lot of useful methods that come along with rails collections like simply doing collection = [] to empty the collection and disassociate all objects with that collection, amongst others. That's my favorite.
So I was going to implement the habtm relationship above when it occurred to me, it's not so much that users have topics or that topics have users, but that there is a specific, Favorited relationship that I want to model.
Furthermore, it seemed that I might (and would, certainly) want to extend this relationship to several other models across the site. I might, for example, want users to be able add articles as their favorites for sharing. Or I might want them to befriend other users or any number of things that might come to be on the site that could be modeled by a favorited relationship. Problem is, with the Article model, for example, it already has a relationship with the Users model, because a user creates and owns an article through its lifecycle, and can be accessed with @article.user. So it would be weird, it seemed, to allow an article to belong to both one user AND many users, and that if the habtm relationship wouldn't work for the Articles model, it probably wouldn't work for any of them.
the solution
So after pondering this for about an hour, considering the potential extensibility of this system, and what it might eventually be used for, and how much time it warranted spending on, it occurred to me that the best way to do this would be to create a Favorite model, and then create a polymorphic association between that model, and all other models that could be favorited would act as favorable.
And that's exactly what I did. I started here, by creating a Favorite model:
user@domain$ cd my_app user@domain$ ./script/generate model Favorite favorable:references user:references
which produces:
#!/db/migrate/XXXXXXXXXXXXXX_create_favorites.rb class CreateFavorites < ActiveRecord::Migration def self.up create_table :favorites do |t| t.references :favorable, :polymorphic => true t.references :user t.timestamps end end def self.down drop_table :favorites end end
Which is everything that you want, sort of. The only part that the model generation script won't produce for you is the :polymorphic => true option for t.references :favorable, so make sure you edit the file and add that part, otherwise this whole exercise is moot and you might as well go pick daffodils or whatever it is that one does when devoid of the wonder that is polymorphic association.
Okay, so assuming that you did that last part, and aren't picking daffodils, let me just explain briefly what you did. While the habtm relationship directly references the :topic model, :favorable allows us to reference ANY model at all, including :user itself, by simply adding the following line to its object model file:
#/app/models/any_model.rb has_many :favorites, :as => :favorable
We'll go back over this and all of the other associations that you'll need in you models for a second, but for the moment let's just bask in what a wonderful thing this is. We're not just joining two models by linking their keys, we're linking their keys AND defining a model type for the second key, allowing us to add as many models under the guise of :favorable as we'd like. Wonderful.
associating the models
Okay, now that we've got the model migration set up we might as well go ahead and migrate it now with rake db:migrate. And some text will fly by and all of the sudden you can access your brand new shiny Favorite model in the console, like this:
user@domain$ cd my_app user@domain$ ./script/console >> f = Favorite.new => #<Favorite id: nil, favorable_id: nil, favorable_type: nil, user_id: nil, created_at: nil, updated_at: nil>
See that favorable_type field? That's what's going to allow us to do make anything at all favorable. Of course, we can't actually apply this yet because we haven't defined any relationships in our models yet, so we'll do that now. Might as well start with the Favorite model since were in the neighborhood:
#!/app/models/favorite.rb class Favorite < ActiveRecord::Base belongs_to :favorable belongs_to :user validates_presence_of :user_id, :favorable_id, :favorable_type end
So we're saying that objects of class Favorite belong to both the User model and whatever model is labelled as favorable. Makes sense, such are the virtues of any many-to-many relationship. We'll append this a bit later with filters and validations, but this is all you'll need for the time being.
In addition to showing that a favorite belongs to both a :user and a :favorable object, we need also to show that they own the favorite, like this in the User model:
#!/app/models/user.rb(favorites) has_many :favorites
And, as we saw before, like this in the Topics model:
#!/app/models/topic.rb has_many :favorites, :as => :favorable
So we save those and all of the sudden we've associated all of our relevant models and can play around with them in the console:
user@domain$ ./script/console >> u = User.find_by_name("jose") => #<User id: 3, name: "jose" ...> >> u.favorites => [] >> t = Topic.find_by_name("humons") => #<Topic id: 6, name: "humons" ...> >> t.favorites => []
So we've got our User set to the u variable, and our Topic set to the t variable. All we need to do is create new Favorite object to connect. Favorite.new seems like the logical thing, but then we'll just get a blank Favorite object. We could also try User.favorites.build, but then we've only got our user_id assigned to it, and still need to assign two more fields.
No, it seems that the best way to build new Favorite objects is from the polymorphic or :favorable side, like this:
>> f = t.favorites.build
=> #<Favorite id: nil, favorable_id: 6, favorable_type: "Topic", user_id: nil, created_at: nil, updated_at: nil>This way, as you see, both the favorable_id and the favorable_type (which is a little more of a pain in the ass) are already set for us. But because we set validates_presence_of :user_id in the Favorite model, we still can't save it yet:
>> f.save => false >> f.errors => #<ActiveRecord::Errors:0xb6d410a0 @errors={"user_id"=>["can't be blank"]}, @base=#<Favorite id: nil, favorable_id: 6, favorable_type: "Topic", user_id: nil, created_at: nil, updated_at: nil>> >> f.user_id = u.id => 3 >> f.save => true
There, we've created our favorite and saved it without errors by adding the user_id. We would have gotten errors the other way, too, by building the Favorite from the user side with u.favorites.build because we also set validations for :favorable_id and :favorable_type. You'll now see that both the Topic and the User share the same Favorite object:
>> u.favorites => [#<Favorite id: 1, favorable_id: 6, favorable_type: "Topic", user_id: 3, created_at: "2009-08-12 06:23:14", updated_at: "2009-08-12 06:23:14">] >> t.favorites => [#<Favorite id: 1, favorable_id: 6, favorable_type: "Topic", user_id: 3, created_at: "2009-08-12 06:23:14", updated_at: "2009-08-12 06:23:14">]
Cool, so this is our bi-directional many-to-many relationship, and everything is good with the world. What do we do with it?
(You may have noticed that we didn't create a controller for our Favorite model. This is because Favorites serve to primarily join User objects with another model, and don't necessarily need to be displayed in their own views. Indeed, the battle of Favorability is fought on foreign soil.)
the :associate_user callback
Now that we've got all of our associations squared away, we need to consider how we're actually going to apply these models across the controllers and views. We already know that we're going to, for the most part, use the :favorable object to build the actual Favorite. That said, a Favorite still can't be saved until a user_id has been associated with it, so let's make it easy on ourselves.
Here at do{block} only registered users can assign favorites, and because a session is created when that user logs in, we can simply assign that session data to the Favorite with the before save callback just in case we neglect to directly assign a user_id:
#!/app/models/favorite.rb before_save :associate_user # Protected Methods protected def associate_user unless self.user_id return self.user_id = session[:user_id] if session[:user_id] return false end end
As the name suggests, the before_save callback sets the Favorite object's user_id to session[:user_id] immediately before the object's save method is called, unless self.user_id is set already. Isn't Ruby such a wonderfully expressive language? Amazing.
Also note that we make the method protected because there's no reason why we'd ever need to call it outside of our Favorite model.
If you aren't using sessions, then you'll either have to implement them, find a way around this trick, or just be sure to assign a user_id each. And if you want to learn more about callbacks or the virtues and uses thereof, there's actually a really outstanding guide along with many other enormously useful and comprehensive ones at guides.rubyonrails.org.
Its worth noting another convention here -- the use of two sequential returns. The idea here is that if there is in fact a session[:user_id] variable set, then that value will be returned and ruby will stop executing code in that method and false will not be returned. Otherwise, it ignores the first return statement and just returns false.
acting favorable
Okay, so that's pretty cool. Our user will automatically be associated with the favorites created while they're logged in. Useful. And for the time being we can get all of their favorite objects by calling:
#!/script/console >> u = User.find 3 >> u.favorites => [#<Favorite id: 1, favorable_id: 6, favorable_type: "Topic", user_id: 3, created_at: "2009-08-12 06:23:14", updated_at: "2009-08-12 06:23:14">]
But what happens when we allow Article objects to act favorable. Or users? u.favorites will return an entire array of all of the user's favorite objects, regardless of the favorable_type.
So basically what we want, then, is a custom method that can find the users favorites by favorable_type, and, if we've got our dancing shoes on, even return those objects of type favorable_type, like Topics, rather than the Favorites themselves. Something like this:
#!/app/models/user.rb def favorable(opts={}) # favorable_type type = opts[:type] ? opts[:type] : :topic type = type.to_s.capitalize # add favorable_id to condition if id is provided con = ["user_id = ? AND favorable_type = ?", self.id, type] # append favorable id to the query if an :id is passed as an option into the # function, and then append that id as a string to the "con" Array if opts[:id] con[0] += " AND favorable_id = ?" con << opts[:id].to_s end # Return all Favorite objects matching the above conditions favs = Favorite.all(:conditions => con) case opts[:delve] when nil, false, :false return favs when true, :true # get a list of all favorited object ids fav_ids = favs.collect{|f| f.favorable_id.to_s} if fav_ids.size > 0 # turn the Capitalized favorable_type into an actual class Constant type_class = type.constantize # build a query that only selects query = [] fav_ids.size.times do query << "id = ?" end type_conditions = [query.join(" AND ")] + fav_ids return type_class.all(:conditions => type_conditions) else return [] end end end
With this code in place, we can do something like this in the console if we just want to get favorites objects favorable_type Topic:
>> u = User.find 3 >> u.favorable(:type => :topic) => [#<Favorite id: 1, favorable_id: 6, favorable_type: "Topic", user_id: 3, created_at: "2009-08-12 06:23:14", updated_at: "2009-08-12 06:23:14">]
Or, better yet, we can actually get the topics objects, and not just the references to them, by calling User.favorable with the :delve => option, like this:
>> u.favorable(:type => :topic, :delve => true) => [<Topic id: 6, name: "humons" ...>]
Were there more Topics that our user had favorited, all of them would have appeared in the output array after =>. Another key element of this solution is that it's terribly Ruby-like, that is, utilizes a Hash to parse options rather than an array of arguments. This is good practice for Ruby and Rails applications in general.
As you'll see in the code above, the :delve option can be called with either true or false (optionally as a symbol), or as nil, which is the default when :delve is not called at all.
viewing your app through ruby-colored glasses
I don't know what that means, really, except that we're going to go a bit backwards here and implement the views before we do the controllers.
In case you hadn't noticed, I'm trying to make this tutorial as situationally agnostic as possible, so that you can take these models/views/controllers and apply them to whatever your unique project is. Such is the beauty of Rails -- utter flexibility.
That said, the situation that I'm going to focus on here is the very one on do{block} itself. Users, when registering for the first time or editing an existing profile, will be presented with a series of checkboxes that represent all possible article topics, and can check or uncheck those boxes to determine what kind of articles they'll receive updates on.
So the first thing I did was make a helper to print out the fields for all of our topics, and designate those topics as checked if the user has favorited them, like this:
#!/app/helpers/users_helper.rb def user_favorites_for(user=@current_user, opts={}) # confirm that the user variable is of class User raise ArgumentError unless user.class == User # Take the type from the options hash and create multiple forms of it type = opts[:type] ? opts[:type] : :topic type = type.to_sym type_plural = type.to_s.pluralize.to_sym type_constant = type.to_s.capitalize.constantize # get all objects of that type all_of_type = type_constant.all # get all favorites of that type for the selected user favs = user.favorable(:type => type) rows = "" # do this if there actually any objects of selected type if all_of_type.size > 0 all_of_type.each do |aot| # determine whether each object has already been favorited by the user, # and check the checkbox if so is_fav = nil favs.each do |f| if f.favorable_id == aot.id is_fav = true break end end is_checked = is_fav ? true : false # override the existing favorites if they've been checked or unchecked by # the user over the course of changing the form if params[:favorites] is_checked = params[:favorites][type_plural].member?(aot.id.to_s) ? true : false end # create the check box, and create the rows for the form table # make it so that all of the checked box values are the selected favorable # ids, and make sure that they're in an array at # params[:favorites][:whatever_type] by setting the names of the checkboxes # as favorites[:whatever_type][] check_box = check_box_tag(aot.name, aot.id, is_checked, :name => "favorites[#{type_plural.to_s}][]") rows += " <tr>" rows += td(:value => :label, :inner => label_tag(aot.name)) rows += td(:value => :input, :inner => check_box) rows += " </tr>" end end # return all of our rower return rows end
The comments really explain this better than any recapitulation, except to say that you may have noticed an alien method called td that I actually used to create the html for the form's table cells.
As any good Railsite, I've delegated this, too, to a templating-system-agnostic helper function called table_cell, or its alias(es) td and tc. All it really does is allow us to encapsulate form inputs and labels in commonly-used form conventions and table-cell classes. Take a look:
#!/app/helpers/users_helper.rb def table_cell(opts={}) param = opts[:param] ? opts[:param] : :class o = " <td " o += " #{param.to_s}=\"#{opts[:value].to_s}\"" if opts[:value] o += ">\n" o += " #{opts[:inner].to_s}\n" if opts[:inner] o += " </td>" return o end alias_method :td, :table_cell alias_method :tc, :table_cell
This saves us from cluttering our views with an excess of table style, and provisions a flexible array of options that can all be called as options from the tc function. It allows us to wrap whatever inner code you'd like inside of a table cell, and give that cell a single parameter with a value It's not much, but this is all we should probably be doing from outside the form partial.
Whatever the case, it makes it a bit easier to implant this table into our new/edit User form, like such:
#!/app/views/users/_form.html.erb <h4 class="center">watch topics</h4> <h6 class="quiet">get updates on new articles</h6> <table class="formBox"> <%= user_favorites_for(@user, :type => :category) %> </table>
Yeah, seriously. It's that easy. Granted, this is just tacked into whatever existing users form you've got (see one of ninety-eight thousand Rails tutorials on creating users for a rails blog), but you've got the idea.
You'll note that I haven't provided any CSS for the tables, as I figured that it's obvious enough how to do. But any broad table/form style selectors that you've already implemented in your CSS should style this new form as well.
the users controller
The last piece of the puzzle, as was alluded to earlier, is the Users controller. There really isn't a lot to do here, as we very smartly kept as much of the data-oriented code in our models, and most of the presentation code in our helpers. But if you want to jump the gun and see what the final product looks like, check out the do{block} registration form.
All that's left now is to create a method that will allow us to actually create and delete favorites based on what was checked in those users forms, and, therefore, what shows up in the params[:favorites][:whavever_type] Array. Something like this:
#!/app/controllers/users_controller.rb protected def update_favorites(opts={}) # default the favorite type to Topic type = opts[:type] ? opts[:type] : :topic # create symbol, plural symbol, and constant objects for selected type type = type.to_sym type_plural = type.to_s.pluralize.to_sym type_constant = type.to_s.capitalize.constantize if params[:favorites] # if any of our favorites input objects are checked, do this: if params[:favorites][type_plural].size > 0 # old_fav = @user.favorable(:type => type) old_fav.each do |of| of.delete unless params[:favorites][type_plural].member?(of.id) end params[:favorites][type_plural].each do |id| current_fav = @user.favorable(:type => type, :id => id) unless current_fav.size > 0 new_fav = type_constant.find(id) new_fav.favorites.build(:user_id => @user.id).save end end end else @user.favorable(:type => type).each do |uf| uf.destroy end end end
Sweet. Now we just have to take that update_favorites method and place it into the create and update methods for your that users controller. Let's suppose that it looks something like this:
#!/app/controllers/users_controller.rb def new @user = User.new end def create @user = User.new(params[:user]) if request.post? if @user.save if User.authenticate(params[:user][:login], params[:user][:password]) ...
Pretty basic, right? You're just creating a new user in the create method and populating its fields with data from the user form. All you have to do to save the favorites that your user has selected is to call update_favorites along with @user.save, like this:
#!/app/controllers/users_controller.rb def create @user = User.new(params[:user]) if request.post? if update_favorites && @user.save if User.authenticate(params[:user][:login], params[:user][:password]) ...
And then we'll do the same thing in our update method, like this:
#!/app/controllers/users_controller.rb def update if request.put? @user = User.find(params[:id]) update_favorites && @user.update_attributes(params[:user]) ...
And that's it. Your users can now add and delete X number of favorites from their user profile, and you can use those favorites for whatever you'd like. It should be noted that the update_favorites method with which I provided you will only update the favorites for a single favorable_type at a time (whatever you pass into it with **:type =>), but it would be easy enough to call this a couple of times or to revise the code so that it updated all favorites data on a given page.
conclusion
If there are any requests for it, or if I get bored, I'll probably post a follow-up to this article with a couple of ideas for how to use these new User favorites. Otherwise, good luck on implementing favorites in your app. I'm curious to hear what people do with it.
no links
3 comments
That's great, jaimec! Glad that it's of use to you. If there's anything else specifically that you'd like a write-up by all means let me know and I'd be happy to put one together.
when i try to run the favorite migration i get a MYSQL error "Can't create table"
Mysql::Error: Can't create table 'favorites.frm' (errno: 150): CREATE TABLE favorites (id int(11) DEFAULT NULL auto_increment PRIMARY KEY, favorable_id int(4), favorable_type varchar(255), profile_id int(4), created_at datetime, updated_at datetime, FOREIGN KEY (favorable_id) REFERENCES favorables (id), FOREIGN KEY (profile_id) REFERENCES profiles (id)) ENGINE=InnoDB
any idea why?
I wished for the a week earlier, and now here it is! I'm saving it for next time - thanks