How to implement a save / favorite functionality in Rails

Sep 03, 2020

Let’s say we’re building a jokes application that allows users to share a bunch of jokes. Now, let’s say we have a new requirement to allow users to save a joke that they particularly liked so they can quickly look that up later.

Let’s assume our app has the following models:

class User < ApplicationRecord
  has_many :jokes
end

class Joke < ApplicationRecord
  belongs_to :user
end

When designing models or database schemas, I find it helpful to think about how I want the interaction between the models to work and then let the requirements drive my design..

In this case, I’d want to be able to do three things.

1. Allow a user to save a joke

current_user.saved_jokes << @joke

2. Retrieve a user’s saved jokes

@jokes = current_user.saved_jokes

3. Get list of users who have saved a joke

@users = @joke.saved_by # should return list of User records

Having considered these requirements, it’s clear that we need a many-to-many relationship between the User and Joke models. A user can have many saved jokes and a joke can be saved by many users.

Create a Join table to model many-to-many relationships

To setup this many-to-many relationship, we need to first create a join table in the database (assuming a SQL database for this example).

Let’s generate a migration to create a jokes_saves join table.

rails g migration CreateJokesSaves user:references joke:references

The corresponding migration should be as follows:

class CreateJokesSaves < ActiveRecord::Migration[6.0]
  def change
    create_table :jokes_saves, id: false do |t|
      t.references :user, null: false, foreign_key: true
      t.references :joke, null: false, foreign_key: true

      t.timestamps
    end

    add_index :jokes_saves, [:user_id, :joke_id], unique: true
  end
end

Make sure to pass id: false to create_table to tell Rails not to create a id column. Apparently having IDs on join tables can end up causing very subtle issues with ActiveRecord!

Also, make sure to add a unique index on the combined user_id and joke_id columns to prevent users from favouriting a joke more than once.

Setup has_and_belongs_to_many association between users and jokes

Now, all that is left is to configure the has_and_belongs_to_many associations.

class User < ApplicationRecord
  has_many :jokes
  has_and_belongs_to_many :saved_jokes, join_table: 'jokes_saves', class_name: 'Joke'
end

class Joke < ApplicationRecord
  belongs_to :user
  has_and_belongs_to_many :saved_by, join_table: 'jokes_saves', class_name: 'User'
end

You can call the associations anything you want, just make sure the join_table and class_name arguments are set correctly.

Use the associations

Now, it’s time to bask in the magic of Rails.

user = User.first
joke = Joke.first

# Save joke
user.saved_jokes << joke

# Fetch saved jokes
user.saved_jokes # [joke]

# Fetch list of users who saved a joke
joke.saved_by # [user]

Goodbye friend.