Convert a belongs_to Association to a has_many :through Association in Ruby on Rails
I was tasked with converting a belongs_to
association to a has_many :through
association. The challenge was that the app was live, and there were existing relationships. I was able to convert the belongs_to
association to a has_many :through
association while still maintaining existing relationships by creating a custom migration file, and updating the existing models.
In order to understand how to create this conversion, let’s create a sample app.
If you want to skip ahead to the solution click here.
1. Create a Sample Rails App
Run the following commands in a new terminal window.
rails new association-converter --database=postgresql
cd association-converter/
rails g scaffold Author name:string
rails g scaffold Book name:string author:references
rails db:migrate
2. Create the belongs_to Association
Update the Book and Author models so they are associated with a belongs_to
association.
class Book < ApplicationRecord
belongs_to :author
end
class Author < ApplicationRecord
has_many :books, dependent: :destroy
end
3. Seed the database
Install Faker and seed the database with sample data.
- Add
gem 'faker', '~> 1.9', '>= 1.9.1'
to yourGemfile
- Run
bundle install
-
Open
seeds.db
and add the following:100.times { |i| Author.create(name: Faker::Name.name) } Author.all.each { |author| author.books.create(name: Faker::Book.title) }
-
Seed the database by running
rails db:seed
5. Open up the Rails Console by runningrails c
. Confirm there is data in the database by running the following:2.3.1 :001 > Author.count (0.6ms) SELECT COUNT() FROM "authors" => 100 2.3.1 :002 >
2.3.1 :002 > Book.count (14.2ms) SELECT COUNT() FROM "books" => 100 2.3.1 :003 >
Book.last.author Book Load (64.1ms) SELECT "books"._ FROM "books" ORDER BY "books"."id" DESC LIMIT ? [["LIMIT", 1]] Author Load (0.3ms) SELECT "authors"._ FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 100], ["LIMIT", 1]] => #<Author id: 100, name: "Shirlee Mayert DDS", created_at: "2018-11-21 22:22:46", updated_at: "2018-11-21 22:22:46">
-
Open up
schema.rb
to confirm the schema looks similar to the following:ActiveRecord::Schema.define(version: 2018_11_22_151441) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" create_table "authors", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "books", force: :cascade do |t| t.string "name" t.bigint "author_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["author_id"], name: "index_books_on_author_id" end add_foreign_key "books", "authors" end
At this point we have set up a simple belongs_to
association. This will be the baseline to understanding how to convert a belongs_to
association to a has_many :through
association.
4. Create a Join Table
-
Open a new terminal and run
rails g model BooksAuthors book:references author:references
.The new migration file should render something similar to the following:
class CreateBooksAuthors < ActiveRecord::Migration[5.2] def change create_table :books_authors do |t| t.references :book, foreign_key: true t.references :author, foreign_key: true end end end
-
Update the migration file to use
def up
anddef down
methods. Specifically, add the following:- In the
def up
method usecreate_table
to create a new join table. - In the
def up
method populate the new join table with the existing relationships. - In the
def up
method remove the existingreference
column. - In the
def down
method add areference
column. - In the
def down
method populate the reference column. - In the
def down
method use thedrop_table
method to remove the join table.
class CreateBooksAuthors < ActiveRecord::Migration[5.2] def up # create join table create_table :books_authors do |t| t.references :book, foreign_key: true t.references :author, foreign_key: true t.timestamps end # populate join table with existing data puts "populating books_authors" Book.all.each do |book| puts "#{book.name} is being added to the books_authors table" BooksAuthor.create(book_id: book.id, author_id: book.author_id) puts "There are #{BooksAuthor.count} books_authors records" end # remove obsolete column puts "removing old association" remove_reference :books, :author, foreign_key: true end def down # add reference column back add_reference :books, :author, foreign_key: true # Using a model after changing its table # https://api.rubyonrails.org/classes/ActiveRecord/Migration.html#class-ActiveRecord::Migration-label-Using+a+model+after+changing+its+table Book.reset_column_information # associate book with author, even though it will just be one. BooksAuthor.all.each do |book_author| Book.find(book_author.book_id).update_attribute( :author_id, book_author.author_id, ) end # remove join table drop_table :books_authors end end
- In the
- Update the database by running
rails db:migrate
in a terminal window. -
Open
schema.rb
to confirm the schema looks similar to the following:ActiveRecord::Schema.define(version: 2018_11_22_151828) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" create_table "authors", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "books", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "books_authors", force: :cascade do |t| t.bigint "book_id" t.bigint "author_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["author_id"], name: "index_books_authors_on_author_id" t.index ["book_id"], name: "index_books_authors_on_book_id" end add_foreign_key "books_authors", "authors" add_foreign_key "books_authors", "books" end
-
Open the Rails Console by running
rails c
. Enter the following to confirm the data was migrated correctly:2.3.1 :001 > BooksAuthor.count (0.1ms) SELECT COUNT(\*) FROM "books_authors" => 100
-
Update the existing models.
- Update
books_author.rb
class BooksAuthor < ApplicationRecord belongs_to :book belongs_to :author end
- Update
book.rb
class Book < ApplicationRecord has_many :books_authors has_many :authors, through: :books_authors, dependent: :destroy end
- Update
author.rb
class Author < ApplicationRecord has_many :books_authors has_many :books, through: :books_authors, dependent: :destroy end
- Update
-
Open a terminal window and run
rails c
. Then run the following:2.3.1 :003 > Book.last.authors Book Load (0.4ms) SELECT "books"._ FROM "books" ORDER BY "books"."id" DESC LIMIT $1 [["LIMIT", 1]] Author Load (0.6ms) SELECT "authors"._ FROM "authors" INNER JOIN "books_authors" ON "authors"."id" = "books_authors"."author_id" WHERE "books_authors"."book_id" = $1 LIMIT $2 [["book_id", 100], ["LIMIT", 11]] => #<ActiveRecord::Associations::CollectionProxy [#<Author id: 100, name: "Claude O'Keefe", created_at: "2018-11-22 15:16:51", updated_at: "2018-11-22 15:16:51">]>
5. Rolling Back The Conversion
If you need to rollback the conversion, follow these steps.
- Open up a terminal window and run
rails db:rollback
. -
The
schema.rb
should look similar to the following:ActiveRecord::Schema.define(version: 2018_11_22_151441) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" create_table "authors", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "books", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "author_id" t.index ["author_id"], name: "index_books_on_author_id" end add_foreign_key "books", "authors" end
-
Open up a terminal and run
rails c
. Then run the following:2.3.1 :001 > Book.last Book Load (0.5ms) SELECT "books".\* FROM "books" ORDER BY "books"."id" DESC LIMIT $1 [["LIMIT", 1]] => #<Book id: 100, name: "To Sail Beyond the Sunset", created_at: "2018-11-22 15:16:52", updated_at: "2018-11-22 15:26:07", author_id: 100> ```4. Finally, make sure to remove the **join table** model, and revert the **Book** and **Author** models back to a **belongs_to** association.