Creating a many to many relationship in Rails

Ruby on-Rails

Ruby on-Rails Problem Overview


This is a simplified example of what I am trying to achieve, I'm relatively new to Rails and am struggling to get my head around relationships between models.

I have two models, the User model and the Category model. A user can be associated with many categories. A particular category can appear in the category list for many users. If a particular category is deleted, this should be reflected in the category list for a user.

In this example:

My Categories table contains five categories:

| ID | Name                       |

| 1 | Sports | | 2 | News | | 3 | Entertainment | | 4 | Technology |

</pre>

My `Users` table contains two users:

<pre>

| ID | Name |

| 1  | UserA                      | 
| 2  | UserB                      |

> UserA may choose Sports and Technology as his categories > > UserB may choose News, Sports and Entertainment > > The sports category is deleted, both UserA and UserB category lists reflect the deletion

I've toyed around with creating a UserCategories table which holds the ids of both a category and user. This kind of worked, I could look up the category names but I couldn't get a cascading delete to work and the whole solution just seemed wrong.

The examples of using the belongs_to and has_many functions that I have found seem to discuss mapping a one-to-one relationship. For example, comments on a blog post.

  • How do you represent this many-to-many relationship using the built-in Rails functionality?
  • Is using a separate table between the two a viable solution when using Rails?

Ruby on-Rails Solutions


Solution 1 - Ruby on-Rails

You want a has_and_belongs_to_many relationship. The guide does a great job of describing how this works with charts and everything:

http://guides.rubyonrails.org/association_basics.html#the-has-and-belongs-to-many-association

You will end up with something like this:

# app/models/category.rb
class Category < ActiveRecord::Base
  has_and_belongs_to_many :users
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_and_belongs_to_many :categories
end

Now you need to create a join table for Rails to use. Rails will not do this automatically for you. This is effectively a table with a reference to each of Categories and Users, and no primary key.

Generate a migration from the CLI like this:

bin/rails g migration CreateCategoriesUsersJoinTable

Then open it up and edit it to match:

For Rails 4.0.2+ (including Rails 5.2):
def change
  # This is enough; you don't need to worry about order
  create_join_table :categories, :users

  # If you want to add an index for faster querying through this join:
  create_join_table :categories, :users do |t|
    t.index :category_id
    t.index :user_id
  end
end
Rails < 4.0.2:
def self.up
  # Model names in alphabetical order (e.g. a_b)
  create_table :categories_users, :id => false do |t|
    t.integer :category_id
    t.integer :user_id
  end

  add_index :categories_users, [:category_id, :user_id]
end

def self.down
  drop_table :categories_users
end

With that in place, run your migrations and you can connect Categories and Users with all of the convenient accessors you're used to:

User.categories  #=> [<Category @name="Sports">, ...]
Category.users   #=> [<User @name="UserA">, ...]
User.categories.empty?

Solution 2 - Ruby on-Rails

The most popular is 'Mono-transitive Association', you can do this:

class Book < ApplicationRecord
  has_many :book_authors
  has_many :authors, through: :book_authors
end

# in between
class BookAuthor < ApplicationRecord
  belongs_to :book
  belongs_to :author
end

class Author < ApplicationRecord
  has_many :book_authors
  has_many :books, through: :book_authors
end

> A has_many :through association is often used to set up a many-to-many > connection with another model. This association indicates that the > declaring model can be matched with zero or more instances of another > model by proceeding through a third model. For example, consider a > medical practice where patients make appointments to see physicians. Ref.: https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association

Solution 3 - Ruby on-Rails

Just complementing coreyward's answer above: If you already have a model that has a belongs_to, has_many relation and you want to create a new relation has_and_belongs_to_many using the same table you will need to:

rails g migration CreateJoinTableUsersCategories users categories

Then,

rake db:migrate

After that, you will need to define your relations:

User.rb:

class Region < ApplicationRecord
  has_and_belongs_to_many :categories
end

Category.rb

class Facility < ApplicationRecord
  has_and_belongs_to_many :users
end

In order to populate the new join table with the old data, you will need to in your console:

User.all.find_each do |u|
  Category.where(user_id: u.id).find_each do |c|
    u.categories <<  c
  end
end

You can either leave the user_id column and category_id column from the Category and User tables or create a migration to delete it.

Solution 4 - Ruby on-Rails

If you want to add additional data on the relationship, the has_many :things, through: :join_table may be what you're looking for. Often times, though, you won't need to additional metadata (like a role) on a join relationship, in which case has_and_belongs_to_many is definitely the simplest way to go (as in the accepted answer for this SO post).

However, let's say, you're building a forum site where you have several forums and need to support users holding different roles within each forum they participate in. It might be useful to allow for tracking how a user is related to a forum on the join itself:

class Forum
  has_many :forum_users
  has_many :users, through: :forum_users

  has_many :admin_forum_users, -> { administrating }, class_name: "ForumUser"
  has_many :viewer_forum_users, -> { viewing }, class_name: "ForumUser"

  has_many :admins, through: :admin_forum_users, source: :user
  has_many :viewers, through: :viewer_forum_users, source: :user
end

class User
  has_many :forum_users
  has_many :forums, through: :forum_users
end

class ForumUser
  belongs_to :user
  belongs_to :forum

  validates :role, inclusion: { in: ['admin', 'viewer'] }

  scope :administrating, -> { where(role: "admin") }
  scope :viewing, -> { where(role: "viewer") }
end

And your migration would look something like this

class AddForumUsers < ActiveRecord::Migration[6.0]
  create_table :forum_users do |t|
      t.references :forum
      t.references :user

      t.string :role

      t.timestamps
  end
end

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionfletcherView Question on Stackoverflow
Solution 1 - Ruby on-RailscoreywardView Answer on Stackoverflow
Solution 2 - Ruby on-RailsDarlan DieterichView Answer on Stackoverflow
Solution 3 - Ruby on-RailsB-MView Answer on Stackoverflow
Solution 4 - Ruby on-RailsJack RatnerView Answer on Stackoverflow