Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ruby on rails dynamic attribute fields from DB using method_missing issues

So, thought I had this working last night, couldve sworn it. Now it no worky, and I figure its about time to ask for help.

Im defining dynamic fields in the database, semi EAV style, and lets just state right now I dont care to hear your opinions on whether EAV is a good idea or not :)

Anyways Im doing it a little differently than Ive done in past, basically when an attribute (or field), is added, I create a add column to a particular attributes table migration and run it (or a remove it one) -- ANYWAYS, because there is a category layer sitting in the middle which is the direct relationship where all the attributes are defined, I cant use the actual attribute name as a column name, as attributes are category specific.

So, if it helps you visualize

    Entity
    belongs_to :category

    Category
    has_many :entities

    EntityAttribute
    belongs_to :category

    EntityAttributeValue
    belongs_to :entity_attribute
    belongs_to :entity        

And EAV table spans horizontally as new attributes are created, with columns labeled attribute_1 attribute_2, which contain the values for that particular entity.

Anyways -- I am trying to make the methods dynamic on the entity model, so I can call @entity.actual_attribute_name, rather than @entity.entity_attribute_value.field_5

Here is the code I thought was working --

    def method_missing(method, *args)

      return if self.project_category.blank?

      puts "Sorry, I don't have #{method}, let me try to find a dynamic one."
      puts "let me try to find a dynamic one"

      keys = self.project_category.dynamic_fields.collect {|o| o.name.to_sym }

      if keys.include?(method)
        field = self.project_category.dynamic_fields.select { |field| field.name.to_sym == method.to_sym && field.project_category.id == self.project_category.id }.first
        fields = self.project_category.dynamic_field_values.select {|field| field.name.to_sym == method }
        self.project_category_field_value.send("field_#{field.id}".to_sym, *args)
      end

    end

Then today as I went back to code, I realized although I could set the attribute in rails console, and it would return the correct field, when I saved the record, the EntityAttributeValue was not being updated (represented as self.project_category_field_value, above.)

So after looking into it further it looked like I just had to add a before_update or before_save callback to manually save the attribute, and thats where I noticed, in the callback, it would re run the method_missing callback, as if the object was being duplicated (and the new object was copy of original object), or something, Im not quite sure. But at somepoint during save process or before, my attribute dissapears into oblivion.

So, well I guess I half way answered my own question after typing it out, I need to set an instance variable and check to see if it exists at the beginningish of my method_missing method (right?) Maybe that's not what's happening I dont know, but Im also asking if there is a better way of doing what I am trying to do.

And if using method_missing is a bad idea, please explain why, as going through posts regarding method missing I heard some people slamming it but not one of those people bothered offering a reasonable explanation as to why method missing was a bad solution.

Thanks in advance.

like image 221
thrice801 Avatar asked Apr 05 '12 01:04

thrice801


2 Answers

You can look at my presentation where I described how to delegate methods to associated models EAV with ActiveRecord

For example we use STI for our Product models and we have associated Attribute models for them.

At first we create the abstract Attribute model

class Attribute < ActiveRecord::Base
  self.abstract_class = true
  attr_accessible :name, :value
  belongs_to :entity, polymorphic: true, touch: true, autosave: true
end

Then all our attribute models are inherited from this class.

class IntegerAttribute < Attribute
end

class StringAttribute < Attribute
end

Now we need to describe the base product class

class Product < ActiveRecord::Base
  %w(string integer float boolean).each do |type|
    has_many :"#{type}_attributes", as: :entity, autosave: true, dependent: :delete_all
  end

  def eav_attr_model(name, type)
    attributes = send("#{type}_attributes")
    attributes.detect { |attr| attr.name == name } || attributes.build(name: name)
  end

  def self.eav(name, type)
    attr_accessor name

    attribute_method_matchers.each do |matcher|
      class_eval <<-EOS, __FILE__, __LINE__ + 1
        def #{matcher.method_name(name)}(*args)
          eav_attr_model('#{name}', '#{type}').send :#{matcher.method_name('value')}, *args
        end
      EOS
    end
  end
end

So we added the #eav_attr_model method which is a proxy method to our associated models and the .eav method which generates attribute methods.

It's all. Now we can create our product models which are inherited from Product class.

class SimpleProduct < Product
  attr_accessible :name

  eav :code, :string
  eav :price, :float
  eav :quantity, :integer
  eav :active, :boolean
end

Usage:

SimpleProduct.create(code: '#1', price: 2.75, quantity: 5, active: true)
product = SimpleProduct.find(1)
product.code     # "#1"
product.price    # 2.75
product.quantity # 5
product.active?  # true

product.price_changed? # false
product.price = 3.50
product.code_changed?  # true
product.code_was       # 2.75

if you need a a more complex solution which allows to create attributes in runtime or use query methods to obtain data you can look at my gem hydra_attribute which implements the EAV for active_record models.

like image 110
Kostya Stepanyuk Avatar answered Sep 29 '22 08:09

Kostya Stepanyuk


That's some seriously intense programming to be going on in the method_missing department. What you should have is something more like this:

def method_missing(name, *args)
  if (method_name = dynamic_attribute_method_for(name))
    method_name.send(*args)
  else
    super
  end
end

You can then try and break this down into two parts. The first is creating a method that decides if it can handle a call with a given name, here dynamic_attribute_method_for, and the second is the actual method in question. The job of the former is to ensure the latter works by the time it is called, possibly using define_method to avoid having to go through all of this again the next time you access the same method name.

That method might look like this:

def dynamic_attribute_method_for(name)
  dynamic_attributes = ...

  type = :reader

  attribute_name = name.to_s.sub(/=$/) do 
    type = :writer
    ''
  end

  unless (dynamic_attributes.include?(attribute_name))
    return
  end

  case (type)
  when :writer
    define_method(name) do |value|
      # Whatever you need
    end
  else
    define_method(name) do
      # Whatever you need
    end
  end

  name
end

I can't tell what's going on in your method as the structure is not clear and it seems highly dependent on the context of your application.

From a design perspective you might find it's easier to make a special-purpose wrapper class that encapsulates all of this functionality. Instead of calling object.attribute_name you'd call object.dynamic_attributes.attribute_name where in this case dynamic_attributes is created on demand:

def dynamic_attributes
  @dynamic_attributes ||= DynamicAccessor.new(self)
end

When that object is initialized it will pre-configure itself with whatever methods are required and you won't have to deal with this method missing stuff.

like image 28
tadman Avatar answered Sep 29 '22 10:09

tadman