rubyinstance-method

Undefined method in some cases for instance method defined in a subclass


After much trial-and-error and searching for an existing answer, there seems to be a fundamental misunderstanding I'm having and would love some clarification and/or direction.

Note in advance: I'm using multiple table inheritance and have good reasons for doing so, so no need to direct me back to STI :)

I have a base model:

class Animal < ActiveRecord::Base
  def initialize(*args)
    if self.class == Animal
      raise "Animal cannot be instantiated directly"
    end
    super
  end
end

And a sub-class:

class Bunny < Animal
  has_one(:bunny_attr)

  def initialize(*args)
    attrs = args[0].extract!(:ear_length, :hop_style)

    super

    self.bunny_attr = BunnyAttr.create!

    bunny_attrs_accessors 

    attrs.each do |key, value|
      self.send("#{key}=", value)
    end

  def bunny_attrs_accessors
    attrs = [:ear_length, :hop_style]

    attrs.each do |att|
      define_singleton_method att do
        bunny_attr.send(att)
      end

      define_singleton_method "#{att}=" do |val|
        bunny_attr.send("#{att}=", val)
        bunny_attr.save!
      end
    end
  end
end

And a related set of data

class BunnyAttr < ActiveRecord::Base
  belongs_to :bunny
end

If I then do something like this:

bunny = Bunny.create!(name: "Foofoo", color: white, ear_length: 10, hop_style: "normal")
bunny.ear_length
Bunny.first.ear_length

bunny.ear_length will return "10", while Bunny.first.ear_length will return "undefined method 'ear_length' for #<Bunny:0x0..>

Why is that and how do I get the second call to return a value?


Solution

  • Try moving the code you currently have in initialize to an after_initialize callback.

    after_initialize do
      # the code above...
    end
    

    When ActiveRecord loads from the database, it doesn't actually call initialize. When you call Bunny.first, ActiveRecord eventually calls the following method:

    def find_by_sql(sql, binds = [])
      result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds)
      column_types = {}
    
      if result_set.respond_to? :column_types
        column_types = result_set.column_types
      else
        ActiveSupport::Deprecation.warn "the object returned from `select_all` must respond to `column_types`"
      end
    
      result_set.map { |record| instantiate(record, column_types) }
    end
    

    And the instantiate method looks like this:

     def instantiate(record, column_types = {})
        klass = discriminate_class_for_record(record)
        column_types = klass.decorate_columns(column_types.dup)
        klass.allocate.init_with('attributes' => record, 'column_types' => column_types)
      end
    

    And init_with...

    def init_with(coder)
      @attributes   = self.class.initialize_attributes(coder['attributes'])
      @column_types_override = coder['column_types']
      @column_types = self.class.column_types
    
      init_internals
    
      @new_record = false
    
      run_callbacks :find
      run_callbacks :initialize
    
      self
    end
    

    init_internals just sets some internal variables, like @readonly, @new_record, etc, so #initialize never actually gets called when you load records from the database. You'll also notice the run_callbacks :initialize that does run when you load from the db.

    Note the above code is extracted from Rails 4.1.1, but much of the initialization process should be the same for other, recent versions of Rails.

    Edit: I was just thinking about this a little more, and you can remove the code where you define the setter methods and then call them if you delegate the methods to BunnyAttr.

    class Bunny < Animal
      has_one :bunny_attr
      delegate :ear_length, :hop_style, to: :bunny_attr, prefix: false, allow_nil: false
    end
    

    This will automatically create the getters and setters for ear_length and hop_style, and it'll track their dirty status for you, too, allowing you to save bunny_attr when you call save on bunny. Setting allow_nil to false will cause ActiveRecord to throw an error if bunny_attr is nil.