Implementation of EAV
        pattern for ActiveRecord
                 models


Kostyantyn Stepanyuk   kostya.stepanyuk@gmail.com   https://github.com/kostyantyn
Entity - Attribute - Value
Schema
Entity Type
Attribute Set
ActiveRecord and EAV
https://github.com/kostyantyn/example_active_record_as_eav
Specification


1. Save Entity Type as string in Entity Table (STI pattern)
2. Keep attributes directly in the model
3. Use Polymorphic Association between Entity and Value
Migration

class CreateEntityAndValues < ActiveRecord::Migration
  def change
    create_table :products do |t|
      t.string :type
      t.string :name
      t.timestamps
    end

    %w(string integer float boolean).each do |type|
      create_table "#{type}_attributes" do |t|
        t.references :entity, polymorphic: true
        t.string :name
        t.send type, :value
        t.timestamps
      end
    end
  end
end
Attribute Models

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

class BooleanAttribute < Attribute
end

class FloatAttribute < Attribute
end

class IntegerAttribute < Attribute
end

class StringAttribute < Attribute
end
Product

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

  class << self
    def eav(name, type)
      class_eval <<-EOS, __FILE__, __LINE__ + 1
        attr_accessible :#{name}
        def #{name};        eav_attr_model('#{name}', '#{type}').value         end
        def #{name}=(value) eav_attr_model('#{name}', '#{type}').value = value end
        def #{name}?;       eav_attr_model('#{name}', '#{type}').value?        end
      EOS
    end
  end
end
Simple Product

class SimpleProduct < Product
  attr_accessible :name

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

class Product < ActiveRecord::Base
  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
Usage

SimpleProduct.create(code: '#1', price: 2.75, quantity: 5, active: true).id
# 1

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

product.code_changed? # false
product.code = 3.50
product.code_changed? # true
product.code_was      # 2.75

SimpleProduct.instance_methods.first(10)
# [:code, :code=, :code_before_type_cast, :code?, :code_changed?, :
code_change, :code_will_change!, :code_was, :reset_code!, :_code]
What about query methods?

class Product < ActiveRecord::Base
  def self.scoped(options = nil)
    super(options).extend(QueryMethods)
  end

 module QueryMethods
   def select(*args, &block)
     super(*args, &block)
   end

   def order(*args)
     super(*args)
   end

    def where(*args)
      super(*args)
    end
  end
end
hydra_attribute
https://github.com/kostyantyn/hydra_attribute
Installation

class Product < ActiveRecord::Base
  attr_accessor :title, :code, :quantity, :price, :active, :description
  define_hydra_attributes do
    string :title, :code
    integer :quantity
    float   :price
    boolean :active
    text    :description
  end
end

class GenerateAttributes < ActiveRecord::Migration
  def up
    HydraAttribute::Migration.new(self).migrate
  end

  def down
    HydraAttribute::Migration.new(self).rollback
  end
end
Helper Methods

Product.hydra_attributes
# [{'code' => :string, 'price' => :float, 'quantity' => :integer, 'active'
=> :boolean}]

Product.hydra_attribute_names
# ['code', 'price', 'quantity', 'active']

Product.hydra_attribute_types
# [:string, :float, :integer, :boolean]

Product.new.attributes
# [{'name' => nil, 'code' => nil, 'price' => nil, 'quantity' => nil,
'active' => nil}]

Product.new.hydra_attributes
# [{'code' => nil, 'price' => nil, 'quantity' => nil, 'active' => nil}]
Where Condition

Product.create(price: 2.50) # id: 1
Product.create(price: nil) # id: 2
Product.create              # id: 3

Product.where(price: 2.50).map(&:id) # [1]
Product.where(price: nil).map(&:id) # [2, 3]
Select Attributes

Product.create(price: 2.50) # id: 1
Product.create(price: nil) # id: 2
Product.create              # id: 3

Product.select(:price).map(&:attributes)
# [{'price' => 2.50}, {'price => nil}, {'price' => nil}]

Product.select(:price).map(&:code)
# ActiveModel::MissingAttributeError: missing attribute: code
Order and Reverse Order

Product.create(title: 'a') # id: 1
Product.create(title: 'b') # id: 2
Product.create(title: 'c') # id: 3

Product.order(:title).first.id                 # 1
Product.order(:title).reverse_order.first.id   # 3
Questions?

Implementation of EAV pattern for ActiveRecord models

  • 1.
    Implementation of EAV pattern for ActiveRecord models Kostyantyn Stepanyuk kostya.stepanyuk@gmail.com https://github.com/kostyantyn
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
    Specification 1. Save EntityType as string in Entity Table (STI pattern) 2. Keep attributes directly in the model 3. Use Polymorphic Association between Entity and Value
  • 8.
    Migration class CreateEntityAndValues <ActiveRecord::Migration def change create_table :products do |t| t.string :type t.string :name t.timestamps end %w(string integer float boolean).each do |type| create_table "#{type}_attributes" do |t| t.references :entity, polymorphic: true t.string :name t.send type, :value t.timestamps end end end end
  • 9.
    Attribute Models class Attribute< ActiveRecord::Base self.abstract_class = true attr_accessible :name, :value belongs_to :entity, polymorphic: true, touch: true, autosave: true end class BooleanAttribute < Attribute end class FloatAttribute < Attribute end class IntegerAttribute < Attribute end class StringAttribute < Attribute end
  • 10.
    Product 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 class << self def eav(name, type) class_eval <<-EOS, __FILE__, __LINE__ + 1 attr_accessible :#{name} def #{name}; eav_attr_model('#{name}', '#{type}').value end def #{name}=(value) eav_attr_model('#{name}', '#{type}').value = value end def #{name}?; eav_attr_model('#{name}', '#{type}').value? end EOS end end end
  • 11.
    Simple Product class SimpleProduct< Product attr_accessible :name eav :code, :string eav :price, :float eav :quantity, :integer eav :active, :boolean end
  • 12.
    Advanced Attribute Methods classProduct < ActiveRecord::Base 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
  • 13.
    Usage SimpleProduct.create(code: '#1', price:2.75, quantity: 5, active: true).id # 1 product = SimpleProduct.find(1) product.code # "#1" product.price # 2.75 product.quantity # 5 product.active? # true product.code_changed? # false product.code = 3.50 product.code_changed? # true product.code_was # 2.75 SimpleProduct.instance_methods.first(10) # [:code, :code=, :code_before_type_cast, :code?, :code_changed?, : code_change, :code_will_change!, :code_was, :reset_code!, :_code]
  • 14.
    What about querymethods? class Product < ActiveRecord::Base def self.scoped(options = nil) super(options).extend(QueryMethods) end module QueryMethods def select(*args, &block) super(*args, &block) end def order(*args) super(*args) end def where(*args) super(*args) end end end
  • 15.
  • 16.
    Installation class Product <ActiveRecord::Base attr_accessor :title, :code, :quantity, :price, :active, :description define_hydra_attributes do string :title, :code integer :quantity float :price boolean :active text :description end end class GenerateAttributes < ActiveRecord::Migration def up HydraAttribute::Migration.new(self).migrate end def down HydraAttribute::Migration.new(self).rollback end end
  • 17.
    Helper Methods Product.hydra_attributes # [{'code'=> :string, 'price' => :float, 'quantity' => :integer, 'active' => :boolean}] Product.hydra_attribute_names # ['code', 'price', 'quantity', 'active'] Product.hydra_attribute_types # [:string, :float, :integer, :boolean] Product.new.attributes # [{'name' => nil, 'code' => nil, 'price' => nil, 'quantity' => nil, 'active' => nil}] Product.new.hydra_attributes # [{'code' => nil, 'price' => nil, 'quantity' => nil, 'active' => nil}]
  • 18.
    Where Condition Product.create(price: 2.50)# id: 1 Product.create(price: nil) # id: 2 Product.create # id: 3 Product.where(price: 2.50).map(&:id) # [1] Product.where(price: nil).map(&:id) # [2, 3]
  • 19.
    Select Attributes Product.create(price: 2.50)# id: 1 Product.create(price: nil) # id: 2 Product.create # id: 3 Product.select(:price).map(&:attributes) # [{'price' => 2.50}, {'price => nil}, {'price' => nil}] Product.select(:price).map(&:code) # ActiveModel::MissingAttributeError: missing attribute: code
  • 20.
    Order and ReverseOrder Product.create(title: 'a') # id: 1 Product.create(title: 'b') # id: 2 Product.create(title: 'c') # id: 3 Product.order(:title).first.id # 1 Product.order(:title).reverse_order.first.id # 3
  • 21.