Changing type of ActiveRecord Class in Rails with Single Table Inheritance
Asked Answered
C

3

30

I have two types of classes:

BaseUser < ActiveRecord::Base 

and

User < BaseUser

which acts_as_authentic using Authlogic's authentication system. This inheritance is implemented using Single Table Inheritance

If a new user registers, I register him as a User. However, if I already have a BaseUser with the same email, I'd like to change that BaseUser to a User in the database without simply copying all the data over to the User from the BaseUser and creating a new User (i.e. with a new id). Is this possible? Thanks.

Concave answered 16/7, 2010 at 6:28 Comment(0)
N
17

You can just set the type field to 'User' and save the record. The in-memory object will still show as a BaseUser but the next time you reload the in-memory object will be a User

>> b=BaseUser.new
>> b.class # = BaseUser

# Set the Type. In-Memory object is still a BaseUser
>> b.type='User'
>> b.class # = BaseUser
>> b.save

# Retrieve the records through both models (Each has the same class)

>> User.find(1).class # = User
>> BaseUser.find(1).class # User
Natasha answered 16/7, 2010 at 8:19 Comment(3)
your answer is working fine for me but it is not running the validations of User class, can you please help me with that?Greff
@Greff if you want to have model validation, you will need to add '!' to the end. This enforces validation, and is common convention. i.e. create!, find_by!, save!Coverture
See the better answer from @Roosevelt belowNatasha
R
104

Steve's answer works but since the instance is of class BaseUser when save is called, validations and callbacks defined in User will not run. You'll probably want to convert the instance using the becomes method:

user = BaseUser.where(email: "[email protected]").first_or_initialize
user = user.becomes(User) # convert to instance from BaseUser to User
user.type = "User"
user.save!
Roosevelt answered 24/12, 2012 at 0:47 Comment(3)
Experimenting with rails c in Rails 3.2.18, I didn't need to call user.becomes in order for the new class's validations to run. Are you sure this is correct?Kendo
You can use user = user.becomes!(User) (with a !) and omit the user.type = "User" line.Dialytic
This Is Terrific. Never heard of that method anywherePerversity
N
17

You can just set the type field to 'User' and save the record. The in-memory object will still show as a BaseUser but the next time you reload the in-memory object will be a User

>> b=BaseUser.new
>> b.class # = BaseUser

# Set the Type. In-Memory object is still a BaseUser
>> b.type='User'
>> b.class # = BaseUser
>> b.save

# Retrieve the records through both models (Each has the same class)

>> User.find(1).class # = User
>> BaseUser.find(1).class # User
Natasha answered 16/7, 2010 at 8:19 Comment(3)
your answer is working fine for me but it is not running the validations of User class, can you please help me with that?Greff
@Greff if you want to have model validation, you will need to add '!' to the end. This enforces validation, and is common convention. i.e. create!, find_by!, save!Coverture
See the better answer from @Roosevelt belowNatasha
M
5

Based on the other answers, I expected this to work in Rails 4.1:

  def update
    @company = Company.find(params[:id])
    # This separate step is required to change Single Table Inheritance types
    new_type = params[:company][:type]
    if new_type != @company.type && Company::COMPANY_TYPES.include?(new_type)
      @company.becomes!(new_type.constantize)
      @company.type = new_type
      @company.save!
    end

    @company.update(company_params)
    respond_with(@company)
  end

It did not, as the type change would not persist. Instead, I went with this less elegant approach, which works correctly:

  def update
    @company = Company.find(params[:id])
    # This separate step is required to change Single Table Inheritance types
    new_type = params[:company][:type]
    if new_type != @company.type && Company::COMPANY_TYPES.include?(new_type)
      @company.update_column :type, new_type
    end

    @company.update(company_params)
    respond_with(@company)
  end

And here are the controller tests I used to confirm the solution:

  describe 'Single Table Inheritance (STI)' do

    class String
      def articleize
        %w(a e i o u).include?(self[0].to_s.downcase) ? "an #{self}" : "a #{self}"
      end
    end

    Company::COMPANY_TYPES.each do |sti_type|
      it "a newly assigned Company of type #{sti_type} " \
        "should be #{sti_type.articleize}" do
        post :create, { company: attributes_for(:company, type: sti_type) },
             valid_session
        expect(assigns(:company)).to be_a(sti_type.constantize)
      end
    end

    Company::COMPANY_TYPES.each_index do |i|
      sti_type, next_sti_type = Company::COMPANY_TYPES[i - 1],
                                Company::COMPANY_TYPES[i]
      it "#{sti_type.articleize} changed to type #{next_sti_type} " \
        "should be #{next_sti_type.articleize}" do
        company = Company.create! attributes_for(:company, type: sti_type)
        put :update, { id: company.to_param, company: { type: next_sti_type } },
            valid_session
        reloaded_company = Company.find(company.to_param)
        expect(reloaded_company).to be_a(next_sti_type.constantize)
      end
    end
  end
Maggio answered 29/9, 2014 at 2:11 Comment(1)
Thanks, this seems to work, with one small change: new_type = params[:listing][:type] if new_type != @listing.type && Listing::LISTING_TYPES.include?(new_type) # we're changing class types here, so need to be careful @listing.update_column :type, new_type @listing = new_type.constantize.find(@listing.id) end I needed to reload the instance manually (calling "reload" didn't work in my case)Amorete

© 2022 - 2024 — McMap. All rights reserved.