rubyruby-on-rails-4activerecorddatamapperruby-datamapper

Activerecord/Datamapper - Have one child belong to many parents


How would you set up an activerecord/datamapper association for the following scenario:

A user creates a "bookshelf" which has many books(a book object just has an isbn that is used to query an api, and has_many review objects associated with it). Let's say Jack creates a "bookshelf" with a book object. Then, lets say that Jill creates a "bookshelf" with the same book object(it has the same id and the same reviews). The book object has the following code as of now:

class Book < ActiveRecord::Base
  has_many :reviews
end

Then, when you view the page for a book (you click the link to it from the "bookshelf" created by Jack) you should see the same book object when you clicked the link to it from Jill's "bookshelf" (e.g. both "bookshelves" have a link to /books/23 because they have the same book object).

I have not been able to figure this out with the has_many association because that requires me to make a new book each time a user adds a book to their "bookshelf." I have trouble understanding the has_and_belongs_to_many relationship, is that what should be used here? I was not able to find any similar questions on SO, so any help is greatly appreciated.

I am using Rails 4 with Ruby 2.1.

Here is a drawing of what I would like to accomplish: Drawing


Solution

  • Yes, you would have to define many-to-many relationship between a Bookshelf and a Book. There are two ways to achieve this in Rails:

    Option 1) Use has_and_belongs_to_many

    See guide

    According to official documentation has_and_belongs_to_many association:

    Specifies a many-to-many relationship with another class. This associates two classes via an intermediate join table. Unless the join table is explicitly specified as an option, it is guessed using the lexical order of the class names. So a join between Developer and Project will give the default join table name of “developers_projects” because “D” precedes “P” alphabetically.

    So, your classes should look like this:

    class Bookshelf < ActiveRecord::Base
      has_and_belongs_to_many :books
    end
    
    class Book < ActiveRecord::Base
      has_and_belongs_to_many :bookshelves
      has_many :reviews
    end
    

    Add a join table generation to your migrations:

    class CreateBooksBookshelvesJoinTable < ActiveRecord::Migration
      def change
        create_table :books_bookshelves, id: false do |t|
          t.belongs_to :book, index: true
          t.belongs_to :bookshelf, index: true
        end
      end
    end
    

    This will create a books_bookshelves table in your database. The table will have no primary key. There would be two foreign keys to your models Book and Bookshelf.

    So, if you call self.books in the context of an user's bookshelf, you will get a list of books in the bookshelf. Vice versa, calling self.bookshelves in the context of a book will return a set of bookshelves the book belongs to.

    The problem with this approach is that every time you add a new book to the bookshelf a new record is created in the database. If you are okay with that, there is no easier option than using has_and_belongs_to_many association. Otherwise, I recommend you to go with the Option #2.

    Option 2) Use has_many :through

    Another option is to use has_many, :through association (see guide). You would have to define one more model to do that, but it might come handy in some use cases (see below for an example).

    Your classes should look like this:

    class Bookshelf < ActiveRecord::Base
      has_many :books, through: :books_bookshelves
      has_many :books_bookshelves
    end
    
    class Book < ActiveRecord::Base
      has_many :bookshelves, through: :books_bookshelves
      has_many :books_bookshelves
      has_many :reviews
    end
    
    class BooksBookshelf < ActiveRecord::Base
      belongs_to :book
      belongs_to :bookshelf
    end
    

    Probably the best thing about using has_many :through association is that it allows you to add custom columns to the join table (e.g. add column count to keep track how many books of the same type are there in the bookshelf).

    The migration would look pretty much the same as the one we used in Option 1, except for the fact we are adding an unique constraint on the foreign keys (please note that adding the constraint is optional):

    class CreateBooksBookshelvesJoinTable < ActiveRecord::Migration
      def change
        create_table :books_bookshelves, id: false do |t|
          t.belongs_to :book, index: true
          t.belongs_to :bookshelf, index: true
          # add your custom columns here
        end
        add_index :books_bookshelves, [:book_id, :bookshelf_id], unique: true # to make sure you won't create duplicate records
      end
    end
    

    By going with this approach, adding a new would be a bit more complicated as you would have to make sure you are not inserting duplicate records in the join table. (However, you may remove the unique constraint from the migration, to achieve exactly the same kind of behavior as you would get with has_and_belongs_to_many.)