Picture_16_medium
Tutorial / December 7, 2008

Splitting up Rails Models to different databases

By Alex Chee/2191 Views/0 Comments
When one database is just not enough, you can split up your model to use several databases with minimal code changes.
Some projects might require accessing data from multiple databases or splitting a model into multiple tables. If the databases are on the same server, you're you can make some changes to a minimal. This isn't a tutorial about database sharding or anything advanced like that (maybe next time), but Fabric (http://github.com/fiveruns/data_fabric/tree/master) would be a good start for that. So let's start off with a classic model, User.

Original Code

Migration:

class CreateUsers < ActiveRecord::Migration
  def self.up
    create_table :users do |t|
      t.string :first_name
      t.string :last_name    
      t.string :login
      t.string :password
      t.string :salt
      t.string :time_zone
      t.integer :friends_count
      t.integer :messages_count
      t.integer :visits_count
      t.string :email
      t.string :gender
      t.string :city
      t.string :state
      t.string :zip_code
      t.string :country
      t.timestamps
    end
  end

  def self.down
    drop_table :users
  end
end

Model:

class User < ActiveRecord::Base

end

Now let's say you want to create 2 entities to represent an User where all the user's personal info will be and User where it shows has all the info that's public. Also, Accounts will be on another database in case you want to make a seperate app to manage Accounts. We can start off by making a new entry in database.yml for the new database and a constant to refer to this database:

In database.yml add:

accounts_development:
  adapter: mysql
  encoding: utf8
  database: dbs_tutorial_accounts_development
  username: root
  password:
  socket: /tmp/mysql.sock

In Development.rb:

ACCOUNTS_DB=YAML::load(open(File.join(RAILS_ROOT, "config/database.yml"),"r"))["accounts_#{RAILS_ENV}"]['database'] unless Object.const_defined?('ACCOUNTS_DB')

Here we setup rails to check database.yml for the location of accounts database when the server is started and store as ACCOUNTS_DB. Next, we make a migration and model for Account.

Account Migration:

class CreateAccounts < ActiveRecord::Migration
  def self.up
    create_table "#{ACCOUNTS_DB}.accounts" do |t|
      t.string :login
      t.string :password
      t.string :salt
      t.string :time_zone
      t.string :gender
      t.string :city
      t.string :state
      t.string :zip_code
      t.string :country
      t.integer :user_id
      t.timestamps
    end
  end

  def self.down
    drop_table "#{ACCOUNTS_DB}.accounts"
  end
end

Account Model:

class Account < ActiveRecord::Base
   belongs_to :user

    def self.table_name
        "#{ACCOUNTS_DB}.accounts"
    end
end

So, our Account will have the user's login, password, location info, and user_id. We Change the table_name also makes it so it will append the database to accounts every time it gets reference. Now we need to make some migrations to transform the database. If this were a table of millions of Users, it would take Active Record a while to transform. So, we will be using straight SQL to do the transformation.

class ConvertUsersToNewFormat < ActiveRecord::Migration
  def self.up
    self.copy_data_to_accounts
    self.convert_users_table
  end

  def self.down
    self.revert_users_table
    self.copy_data_to_users
  end
  
  def self.copy_data_to_accounts
    User.connection.execute("
      INSERT INTO #{Account.table_name} (id, 
        user_id,
        first_name,
        last_name,
        login,
        password,
        salt,
        time_zone,
        email,
        gender,
        city,
        state,
        zip_code,
        country,
        updated_at,
        created_at)
      SELECT users.id,
        users.id,
        users.first_name,
        users.last_name,
        users.login,
        users.password,
        users.salt,
        users.time_zone,
        users.email,
        users.gender,
        users.city,
        users.state,
        users.zip_code,
        users.country,
        users.updated_at,
        users.created_at
      FROM users;
    ")
  end
  
  def self.convert_users_table
    User.connection.execute("ALTER TABLE users DROP COLUMN first_name,
      DROP COLUMN last_name,
      DROP COLUMN login,
      DROP COLUMN password,
      DROP COLUMN salt,
      DROP COLUMN time_zone,
      DROP COLUMN email,
      DROP COLUMN gender,
      DROP COLUMN city,
      DROP COLUMN state,
      DROP COLUMN zip_code,
      DROP COLUMN country;
    ")    
  end
  
  def self.revert_users_table
    User.connection.execute("ALTER TABLE users ADD COLUMN first_name VARCHAR(255),
      ADD COLUMN last_name VARCHAR(255),
      ADD COLUMN login VARCHAR(255),
      ADD COLUMN password VARCHAR(255),
      ADD COLUMN salt VARCHAR(255),
      ADD COLUMN time_zone VARCHAR(255),
      ADD COLUMN email VARCHAR(255),
      ADD COLUMN gender VARCHAR(255),
      ADD COLUMN city VARCHAR(255),
      ADD COLUMN state VARCHAR(255),
      ADD COLUMN zip_code VARCHAR(255),
      ADD COLUMN country VARCHAR(255);
    ")    
  end
  
  
  def self.copy_data_to_users
    User.connection.execute("
      INSERT INTO users (
        first_name,
        last_name,
        login,
        password,
        salt,
        time_zone,
        email,
        gender,
        city,
        state,
        zip_code,
        country)
      SELECT #{Account.table_name}.first_name,
        #{Account.table_name}.last_name,
        #{Account.table_name}.login,
        #{Account.table_name}.password,
        #{Account.table_name}.salt,
        #{Account.table_name}.time_zone,
        #{Account.table_name}.email,
        #{Account.table_name}.gender,
        #{Account.table_name}.city,
        #{Account.table_name}.state,
        #{Account.table_name}.zip_code,
        #{Account.table_name}.country
      FROM #{Account.table_name};
    ")
  end
end

The migration just copies data from users to accounts and transforms the Users table. The trick here is to make the user.id and account.id the same. If you have an another table that references users, but now accounts, it would take hours if you have millions of records to the transform but minutes to just rename the user_id column to account_id.

Now that we have the database all worked out, the next part is make sure the controllers and models are updated. We can go through every reference of user.login, user.password, etc... and change them to user.account.login, user.account.password, etc..., but that would not be fun for a well established code base with thousands of lines of code. So the lazy way is to use delegate method:

delegate :login, :profiles
delegate :login=, :profiles

But this is Ruby, let's do some metaprogramming to get more lazier and create a method that takes in a list of attributes and delete it. Here's a method Ryan Graver wrote:

config/initializers/core_extension.rb

class ActiveRecord::Base
  def self.delegate_attrs(*atts)
    opt = atts.pop
    atts.each do |att|
      delegate att, opt
      delegate "#{att}?", opt
      delegate "#{att}=", opt
    end
  end
end

Now that we have that method setup, we modify User to use it, and might as well create a new account when a user is created.

New User.rb

class User < ActiveRecord::Base
  has_one :account
  
  delegate_attrs :login, 
    :password,
    :salt,
    :login, 
    :first_name,
    :last_name,
    :time_zone,
    :email,
    :gender,
    :city,
    :state,
    :zip_code,
    :country,
    :to => :account

  after_update :make_account

  protected
  def make_account
    self.account.save
  end
end

After that, you should not have to modify anything else to get the app working like before. user.login or user.account.login should work. Now you have a rails app using 2 databases and not much controller and view code to change to get it working. I had the code for this tutorial below, one as a blank and another as the result.

Comments

Interesting post?  Show some love and post a comment!
Using pieces of this code?  Post a link!

Add a Comment