3

I'm trying to make a STI Base model which changes automatically to inherited class like that:

#models/source/base.rb
class Source::Base < ActiveRecord::Base
  after_initialize :detect_type

  private
  def detect_type
    if (/(rss)$/ ~= self.url)
      self.type = 'Source::RSS'
    end
  end
end

#models/source/rss.rb
class Source::RSS < Source::Base
  def get_content
    puts 'Got content from RSS'
  end
end

And i want such behavior:

s = Source::Base.new(:url => 'http://stackoverflow.com/rss')
s.get_content #=> Got content from RSS

s2 = Source::Base.first # url is also ending rss
s2.get_content #=> Got content from RSS
pvf
  • 82
  • 1
  • 6
  • I know there is a method **becomes** which may be used as self.becomes(Source::RSS) or anything else, but how to use it while initializing class? – pvf Sep 24 '15 at 22:07

3 Answers3

3

There are (at least) three ways to do this:

1. Use a Factory method

@Alejandro Babio's answer is a good example of this pattern. It has very few downsides, but you have to remember to always use the factory method. This can be challenging if third-party code is creating your objects.

2. Override Source::Base.new

Ruby (for all its sins) will let you override new.

class Source::Base < ActiveRecord::Base
  def self.new(attributes)
    base = super
    return base if base.type == base.real_type
    base.becomes(base.real_type)
  end

  def real_type
    # type detection logic
  end
end

This is "magic", with all of the super cool and super confusing baggage that can bring.

3. Wrap becomes in a conversion method

class Source::Base < ActiveRecord::Base
  def become_real_type
    return self if self.type == self.real_type
    becomes(real_type)
  end

  def real_type
    # type detection logic
  end
end

thing = Source::Base.new(params).become_real_type

This is very similar to the factory method, but it lets you do the conversion after object creation, which can be helpful if something else is creating the object.

James Mason
  • 4,246
  • 1
  • 21
  • 26
  • 2. surely looks promising, but rails will actually return the super type as becomes does not change the type of the in-memory instance. try it out. – Alexander Presber Jun 07 '16 at 16:27
  • `becomes` returns a new instance of the requested class with a copy of the original instance's attributes. I've had issues where the `type` attribute isn't correctly persisted when calling `becomes` on an existing record, but since this is a new record that shouldn't come up. – James Mason Jun 07 '16 at 23:57
2

Another option would be to use a polymorphic association, your classes could look like this:

class Source < ActiveRecord::Base
  belongs_to :content, polymorphic: true
end

class RSS < ActiveRecord::Base
  has_one :source, as: :content
  validates :source, :url, presence: true
end

When creating an instance you'd create the the source, then create and assign a concrete content instance, thus:

s = Source.create
s.content = RSS.create url: exmaple.com

You'd probably want to accepts_nested_attributes_for to keep things simpler.

Your detect_type logic would sit either in a controller, or a service object. It could return the correct class for the content, e.g. return RSS if /(rss)$/ ~= self.url.


With this approach you could ask for Source.all includes: :content, and when you load the content for each Source instance, Rails' polymorphism will instanciate it to the correct type.

davetapley
  • 17,000
  • 12
  • 60
  • 86
1

If I were you I would add a class method that returns the right instance.

class Source::Base < ActiveRecord::Base
  def self.new_by_url(params)
    type = if (/(rss)$/ ~= params[:url])
      'Source::RSS'
    end
    raise 'invalid type' unless type
    type.constantize.new(params)
  end
end

Then you will get the behavior needed:

s = Source::Base.new_by_url(:url => 'http://stackoverflow.com/rss')
s.get_content #=> Got content from RSS

And s will be an instance of Source::RSS.

Note: after read your comment about becomes: its code uses klass.new. And new is a class method. After initialize, your object is done and it is a Source::Base, and there are no way to change it.

Alejandro Babio
  • 5,189
  • 17
  • 28