1

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?

uhezay
  • 341
  • 1
  • 2
  • 11

2 Answers2

1

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.

Sean Hill
  • 14,978
  • 2
  • 50
  • 56
  • Thanks so much for this great explanation! Wondering if you can clarify a bit more. I only ended up moving bunny_attr_accessors into the after_initialize as I need the other lines to run when a new Bunny is created. Once I did that, I was surprised that the "attrs.each do |key, value| { self.send("#{key}=", value) }" didn't throw an error since those setters were defined in bunny_attr_accessors. Wondering if you can shed some light on the order of operations here? – uhezay Jul 01 '14 at 02:41
  • Well, if you're calling `bunny_attr_accessors` before you call `self.send "#{key}=", value`, then it is defining the setter attributes before you try to set them. Might I suggest an alternative? I'll update the answer above. – Sean Hill Jul 01 '14 at 02:59
  • No, I had removed bunny_attr_accessors from initialize and only put it in after_initialize, so my guess would have been that the setters would be defined after `self.send`. I forgot about delegation, that seems like a much better solution, but I'm still curious about the order just for future knowledge. Thanks for all the info! – uhezay Jul 01 '14 at 06:59
  • 1
    Ah, okay, now I understand. You called `super` on the second line of the `#initialize` method, which called the superclass's `#initialize` method, which fires the same `run_callbacks :initialize`, causing your callback to run before the rest of your initialize method finished running. – Sean Hill Jul 01 '14 at 13:53
  • 1
    Awesome, thanks for the explanation! I also ended up needing to delegate the setters: `delegate :ear_length, :ear_length=, :hop_style, :hop_style=, to: :bunny_attr, prefix: false, allow_nil: false' – uhezay Jul 01 '14 at 18:12
  • Ah, yeah, I didn't think about delegating the setters. Thanks for the update. – Sean Hill Jul 01 '14 at 21:01
0

The delegation described in the answer from Sean worked perfectly, but I wanted something more generic as I'm going to have quite a few "Animals" and didn't want to have to update the delegate line every time I added a new column to BunnyAttr, etc. and I was trying to move as much code as I could up to the Animal class.

I then stumbled upon this blog posting and have decided to go the route of using method_missing in the Bunny class (eventually will define a version in the Animal class where I pass the attr class).

def method_missing(method_name, *args, &block)
  bunny_attr.respond_to?(method_name) ?
  bunny_attr.send(method_name, *args) :
  super
end

Would of course like comments on why this is a bad idea, if any.

uhezay
  • 341
  • 1
  • 2
  • 11