91

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                 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

My Users table contains two users:

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| 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?
Community
  • 1
  • 1
fletcher
  • 13,380
  • 9
  • 52
  • 69
  • Actually, there's nothing wrong with using `UserCategory` model, it's even desirable in most cases. You just need to use `User.has_many :categories, through: :user_categories`. Maybe, a better name could be found though. – RocketR Jul 20 '12 at 13:25
  • 1
    For others, there is a good rails cast here about the subject: http://railscasts.com/episodes/47-two-many-to-many – Dofs Jan 26 '13 at 14:31

5 Answers5

192

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?
coreyward
  • 77,547
  • 20
  • 137
  • 166
  • 11
    One additional tip: create a composite index for the table. More here http://stackoverflow.com/a/4381282/14540 – kolrie Feb 26 '13 at 21:47
  • 1
    @kolrie Great point; I added to the example here for the sake of being thorough. Cheers! – coreyward Feb 27 '13 at 05:04
  • 4
    dumb question, does this mean it's necessary to create a new model? – tvieira Jan 09 '14 at 20:20
  • 1
    has_and_belongs_to_many does not need/create model. If you need to work directly with the relation or need to add attributes to the relationship, you should use has_many :through option. Follow the link in the answer for detailed info. – Chong Yu Sep 29 '14 at 19:53
  • 17
    Another thing I noticed is the naming convention of the joining table. It has to be alphabetically ordered. Using the above example, the table name has to be categories_users and not users_categories. "c" before "u" in this case. – Chong Yu Sep 29 '14 at 19:55
  • I do this and can see the join-table is created in the schema but I get NoMethodError when I run `User.categories` etc. Any ideas what I could be missing? – Sagar Pandya Apr 20 '19 at 23:32
  • 1
    @SagarPandya `categories` is a method on an instance of `User`, not the class. – coreyward Apr 23 '19 at 17:48
  • Why doesn't the categories_users table have an id? (`:id => false do |t|`) – rosalynnas Jul 25 '19 at 15:40
  • 2
    @ashwood You don't need a primary key because there's only a single instance of the relationship between. – coreyward Jul 25 '19 at 19:28
17

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

Kiong
  • 798
  • 1
  • 8
  • 28
Darlan Dieterich
  • 2,369
  • 1
  • 27
  • 37
7

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.

B-M
  • 1,248
  • 9
  • 19
4

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
Jack Ratner
  • 101
  • 4
1

Side note: here's how you can add relationships to records once you've set up the relationship.

category1.users << user1
user2.categories << category2

Here's how to delete them

category1.users.delete(user1)
user2.categories.delete(category2)
Bruno Degomme
  • 883
  • 10
  • 11