Creating an Extensible User Favorites System in Rails

by Mike Zazaian at 2009-08-12 04:57:26 UTC in rails

a comprehensive guide to laying down the foundation for and implementing a powerful, custom favorites system

3 comments no links

the 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.

3 comments

jaimec at 2009-08-14 16:00:39 UTC

I wished for the a week earlier, and now here it is! I'm saving it for next time - thanks

Mike Zazaian at 2009-08-14 16:05:47 UTC

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.

mike mitchell at 2010-06-02 18:27:19 UTC

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?

Comments closed

latest links

Help.GitHub - Multiple SSH keys The article from github help mirroring this process
ones zeros majors and minors ones zeros majors and minors: esoteric adventures in solipsism, by chris wanstrath
ActiveScaffold A Ruby on Rails plugin for dynamic, AJAX CRUD interfaces

login

register activate reset

feeds

articles/rss

topics

staff

editor

about

doblock focuses on ruby, rails, and all things that can help ruby and/or rails programmers hone their skills.

Techniques, tutorials, news, and even free open-source applications, doblock seeks to fill in the cracks of the ruby/rails blogosphere.

doblock v. 0.10.1 powered by Rails