Refactor: renaming a model/table name

1:20 PM September 12 2025 Refactoring

Refactoring a model name and the database table name requires good unit tests and integration tests with coverage to the model that we are about to refactor. Using a gem like https://github.com/simplecov-ruby/simplecov helps with this. I found it useful even in small apps.

After you make sure that your tests are good enough, start taking notes on which files you have references for that model and macros that you may have in place for it. For example, the model Message may have methods generated by the hasmany macro, like messages and messageids, and some others for other associations. See more here https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html

Use those notes as a to-do list to read and start making the necessary changes.

Database migrations

You could use self.table_name to avoid this, but that defeats the purpose of this kind of refactoring.

Let's first change the table name. In some scenarios just renametable :oldtablename, :newtable_name will do the trick. But if you have indices, foreign keys constraints and/or join tables, you need to take care of that in this migration too

For example, this migration will create the new refactored table, then recreate the index, set the foreign key, and then insert the old table data into the new table. Finally we drop the old table.

The down direction of this migration does the same but in the opposite way.

class RenameMessagesToDirectMessages < ActiveRecord::Migration[8.1]
  def up
    return unless table_exists?(:messages)

    create_table :direct_messages do |t|
      ...(whatever columns you defined here)
    end

    add_index :direct_messages, :contact_id, name: "index_direct_messages_on_contact_id"
    add_foreign_key :direct_messages, :contacts

    execute <<~SQL
      INSERT INTO direct_messages (id, contact_id, content, created_at, direction, sent_at, updated_at)
      SELECT id, contact_id, content, created_at, direction, sent_at, updated_at FROM messages;
    SQL

    drop_table :messages
  end

  def down
    return unless table_exists?(:direct_messages)

    create_table :messages do |t|
      ...(whatever columns you defined here)
    end

    add_index :messages, :contact_id, name: "index_messages_on_contact_id"
    add_foreign_key :messages, :contacts

    execute <<~SQL
      INSERT INTO messages (id, contact_id, content, created_at, direction, sent_at, updated_at)
      SELECT id, contact_id, content, created_at, direction, sent_at, updated_at FROM direct_messages;
    SQL

    drop_table :direct_messages
  end
end

If you dont need full control of each step, you can also have a migration like this one:

class RenameShopsToBusinesses < ActiveRecord::Migration[5.0]
  def change
    remove_index :messages, :contact_id
    rename_table :messages, :direct_messages
    add_index    :direct_messages, :contact_id
  end
end

Troubleshooting

If you manually insert data instead of renaming the database table, you'll likely run into the same issue I did: the primary key sequence doesn't advance after the INSERT in the migration. Because of this, when I tried to create a new direct_message, the system attempted to use id=1 instead of continuing the sequence. To fix this, I wrote the following migration:

class FixDirectMessagesPrimaryKeySequence < ActiveRecord::Migration[8.1]
  def up
    execute <<~SQL
      SELECT setval(
        pg_get_serial_sequence('direct_messages','id'),
        COALESCE((SELECT MAX(id) FROM direct_messages), 0),
        true
      );
    SQL
  end

  def down
    # There is no reverse for this operation, the sequence value will advance naturally
  end
end

pg_get_serial_sequence will get the sequencename for the directmessages.id
COALESCE(...) will get the latest id.
setval(seq_name, latest_recorded_value, true) updates the sequence. It uses the sequence and the latest ID to update the sequence, continuing from the latest ID that we have.

Running the test suite was good enough, I did a full regression test too ofc but everything was already covered