module ActiveRecord # :nodoc:
module Has # :nodoc:
##
# HasCustomFields allow for the Entity-attribute-value model (EAV), also
# known as object-attribute-value model and open schema on any of your ActiveRecord
# models.
#
module CustomFields
ALLOWABLE_TYPES = ['select', 'boolean', 'text', 'date']
Object.const_set('TagFacade', Class.new(Object)).class_eval do
def initialize(object_with_custom_fields, scope, scope_id)
@object = object_with_custom_fields
@scope = scope
@scope_id = scope_id
end
def [](tag)
# puts "** Calling get_custom_field_attribute for #{@object.class},#{tag},#{@scope},#{@scope_id}"
return @object.get_custom_field_attribute(tag, @scope, @scope_id)
end
end
Object.const_set('ScopeIdFacade', Class.new(Object)).class_eval do
def initialize(object_with_custom_fields, scope)
@object = object_with_custom_fields
@scope = scope
end
def [](scope_id)
# puts "** Returning a TagFacade for #{@object.class},#{@scope},#{scope_id}"
return TagFacade.new(@object, @scope, scope_id)
end
end
Object.const_set('ScopeFacade', Class.new(Object)).class_eval do
def initialize(object_with_custom_fields)
@object = object_with_custom_fields
end
def [](scope)
# puts "** Returning a ScopeIdFacade for #{@object.class},#{scope}"
return ScopeIdFacade.new(@object, scope)
end
end
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
##
# Will make the current class have eav behaviour.
#
# The following options are available on for has_custom_fields to modify
# the behavior. Reasonable defaults are provided:
#
# * value_class_name:
# The class for the related model. This defaults to the
# model name prepended to "Attribute". So for a "User" model the class
# name would be "UserAttribute". The class can actually exist (in that
# case the model file will be loaded through Rails dependency system) or
# if it does not exist a basic model will be dynamically defined for you.
# This allows you to implement custom methods on the related class by
# simply defining the class manually.
# * table_name:
# The table for the related model. This defaults to the
# attribute model's table name.
# * relationship_name:
# This is the name of the actual has_many
# relationship. Most of the type this relationship will only be used
# indirectly but it is there if the user wants more raw access. This
# defaults to the class name underscored then pluralized finally turned
# into a symbol.
# * foreign_key:
# The key in the attribute table to relate back to the
# model. This defaults to the model name underscored prepended to "_id"
# * name_field:
# The field which stores the name of the attribute in the related object
# * value_field:
# The field that stores the value in the related object
def has_custom_fields(options = {})
# Provide default options
options[:fields_class_name] ||= self.name + 'Field'
options[:fields_table_name] ||= options[:fields_class_name].tableize
options[:fields_relationship_name] ||= options[:fields_class_name].tableize.to_sym
options[:values_class_name] ||= self.name + 'Attribute'
options[:values_table_name] ||= options[:values_class_name].tableize
options[:relationship_name] ||= options[:values_class_name].tableize.to_sym
options[:foreign_key] ||= self.name.foreign_key
options[:base_foreign_key] ||= self.name.underscore.foreign_key
options[:name_field] ||= 'name'
options[:value_field] ||= 'value'
options[:parent] = self.name
::Rails.logger.debug("OPTIONS: #{options.inspect}")
puts("OPTIONS: #{options.inspect}")
# Init option storage if necessary
cattr_accessor :custom_field_options
self.custom_field_options ||= Hash.new
# Return if already processed.
return if self.custom_field_options.keys.include? options[:values_class_name]
# Attempt to load related class. If not create it
begin
Object.const_get(options[:values_class_name])
rescue
Object.const_set(options[:fields_class_name],
Class.new(::CustomFields::CustomFieldBase)).class_eval do
set_table_name options[:fields_table_name]
def self.reloadable? #:nodoc:
false
end
end
::CustomFields.const_set(options[:fields_class_name], Object.const_get(options[:fields_class_name]))
Object.const_set(options[:values_class_name],
Class.new(ActiveRecord::Base)).class_eval do
cattr_accessor :custom_field_options
belongs_to options[:fields_relationship_name],
:class_name => '::CustomFields::' + options[:fields_class_name].singularize
alias_method :field, options[:fields_relationship_name]
def self.reloadable? #:nodoc:
false
end
def validate
field = self.field
raise "Couldn't load field" if !field
if field.style == "select" && !self.value.blank?
# raise self.field.select_options.find{|f| f == self.value}.to_s
if field.select_options.find{|f| f == self.value}.nil?
raise "Invalid option: #{self.value}. Should be one of #{field.select_options.join(", ")}"
self.errors.add_to_base("Invalid option: #{self.value}. Should be one of #{field.select_options.join(", ")}")
return false
end
end
end
end
::CustomFields.const_set(options[:values_class_name], Object.const_get(options[:values_class_name]))
end
# Store options
self.custom_field_options[self.name] = options
# Only mix instance methods once
unless self.included_modules.include?(ActiveRecord::Has::CustomFields::InstanceMethods)
send :include, ActiveRecord::Has::CustomFields::InstanceMethods
end
# Modify attribute class
attribute_class = Object.const_get(options[:values_class_name])
base_class = self.name.underscore.to_sym
attribute_class.class_eval do
belongs_to base_class, :foreign_key => options[:base_foreign_key]
alias_method :base, base_class # For generic access
end
# Modify main class
class_eval do
has_many options[:relationship_name],
:class_name => options[:values_class_name],
:table_name => options[:values_table_name],
:foreign_key => options[:foreign_key],
:dependent => :destroy
# The following is only setup once
unless method_defined? :read_attribute_without_custom_field_behavior
# Carry out delayed actions before save
after_validation :save_modified_custom_field_attributes, :on => :update
private
alias_method_chain :read_attribute, :custom_field_behavior
alias_method_chain :write_attribute, :custom_field_behavior
end
end
create_attribute_table
end
def custom_field_fields(scope, scope_id)
options = custom_field_options[self.name]
klass = Object.const_get(options[:fields_class_name])
return klass.send("find_all_by_#{scope}_id", scope_id, :order => :id)
end
end
module InstanceMethods
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
##
# Rake migration task to create the versioned table using options passed to has_custom_fields
#
def create_attribute_table(options = {})
options = custom_field_options[self.name]
klass = Object.const_get(options[:fields_class_name])
return if connection.tables.include?(options[:values_table_name])
# todo: get the real pkey type and name
scope_fkeys = options[:scopes].collect{|s| "#{s.to_s}_id"}
ActiveRecord::Base.transaction do
self.connection.create_table(options[:fields_table_name], options) do |t|
t.string options[:name_field], :null => false
t.string :style, :null => false
t.string :select_options
scope_fkeys.each do |s|
t.integer s
end
t.timestamps
end
self.connection.add_index options[:fields_table_name], scope_fkeys + [options[:name_field]], :unique => true
# add foreign keys for scoping tables
options[:scopes].each do |s|
self.connection.execute <<-FOO
alter table #{options[:fields_table_name]}
add foreign key (#{s.to_s}_id)
references
#{eval(s.to_s.classify).table_name}(#{eval(s.to_s.classify).primary_key})
FOO
end
# add xor constraint
if !options[:scopes].empty?
self.connection.execute <<-FOO
alter table #{options[:fields_table_name]} add constraint scopes_xor check
(1 = #{options[:scopes].collect{|s| "(#{s.to_s}_id is not null)::integer"}.join(" + ")})
FOO
end
self.connection.create_table(options[:values_table_name], options) do |t|
t.integer options[:foreign_key], :null => false
t.integer options[:fields_table_name].foreign_key, :null => false
t.string options[:value_field], :null => false
t.timestamps
end
self.connection.add_index options[:values_table_name], options[:foreign_key]
self.connection.add_index options[:values_table_name], options[:fields_table_name].foreign_key
self.connection.execute <<-FOO
alter table #{options[:values_table_name]}
add foreign key (#{options[:fields_table_name].foreign_key})
references #{options[:fields_table_name]}(#{eval(options[:fields_class_name]).primary_key})
FOO
end
end
##
# Rake migration task to drop the attribute table
#
def drop_attribute_table(options = {})
options = custom_field_options[self.name]
self.connection.drop_table options[:values_table_name]
end
def drop_field_table(options = {})
options = custom_field_options[self.name]
self.connection.drop_table options[:fields_table_name]
end
end
def get_custom_field_attribute(attribute_name, scope, scope_id)
read_attribute_with_custom_field_behavior(attribute_name, scope, scope_id)
end
def set_custom_field_attribute(attribute_name, value, scope, scope_id)
write_attribute_with_custom_field_behavior(attribute_name, value, scope, scope_id)
end
def custom_fields=(custom_fields_data)
custom_fields_data.each do |scope, scoped_ids|
scoped_ids.each do |scope_id, attrs|
attrs.each do |k, v|
self.set_custom_field_attribute(k, v, scope, scope_id)
end
end
end
end
def custom_fields
return ScopeFacade.new(self)
end
private
##
# Called after validation on update so that eav attributes behave
# like normal attributes in the fact that the database is not touched
# until save is called.
#
def save_modified_custom_field_attributes
return if @save_attrs.nil?
@save_attrs.each do |s|
if s.value.nil? || (s.respond_to?(:empty) && s.value.empty?)
s.destroy if !s.new_record?
else
s.save
end
end
@save_attrs = []
end
def get_value_object(attribute_name, scope, scope_id)
::Rails.logger.debug("scope/id is: #{scope}/#{scope_id}")
options = custom_field_options[self.class.name]
model_fkey = options[:foreign_key]
fields_class = options[:fields_class_name]
values_class = options[:values_class_name]
value_field = options[:value_field]
fields_fkey = options[:fields_table_name].foreign_key
fields = Object.const_get(fields_class)
values = Object.const_get(values_class)
::Rails.logger.debug("fkey is: #{fields_fkey}")
::Rails.logger.debug("fields class: #{fields.to_s}")
::Rails.logger.debug("values class: #{values.to_s}")
f = fields.send("find_by_name_and_#{scope}_id", attribute_name, scope_id)
raise "No field #{attribute_name} for #{scope} #{scope_id}" if f.nil?
::Rails.logger.debug("field: #{f.inspect}")
field_id = f.id
model_id = self.id
value_object = values.send("find_by_#{model_fkey}_and_#{fields_fkey}", model_id, field_id)
if value_object.nil?
value_object = values.new model_fkey => self.id,
fields_fkey => f.id
end
return value_object
end
##
# Overrides ActiveRecord::Base#read_attribute
#
def read_attribute_with_custom_field_behavior(attribute_name, scope = nil, scope_id = nil)
return read_attribute_without_custom_field_behavior(attribute_name) if scope.nil?
value_object = get_value_object(attribute_name, scope, scope_id)
case value_object.field.style
when "date"
::Rails.logger.debug("reading date object: #{value_object.value}")
return Date.parse(value_object.value) if value_object.value
end
return value_object.value
end
##
# Overrides ActiveRecord::Base#write_attribute
#
def write_attribute_with_custom_field_behavior(attribute_name, value, scope = nil, scope_id = nil)
return write_attribute_without_custom_field_behavior(attribute_name, value) if scope.nil?
::Rails.logger.debug("attribute_name(#{attribute_name}) value(#{value.inspect}) scope(#{scope}) scope_id(#{scope_id})")
value_object = get_value_object(attribute_name, scope, scope_id)
case value_object.field.style
when "date"
::Rails.logger.debug("date object: #{value["date(1i)"].to_i}, #{value["date(2i)"].to_i}, #{value["date(3i)"].to_i}")
begin
new_date = !value["date(1i)"].empty? && !value["date(2i)"].empty? && !value["date(3i)"].empty? ?
Date.civil(value["date(1i)"].to_i, value["date(2i)"].to_i, value["date(3i)"].to_i) :
nil
rescue ArgumentError
new_date = nil
end
value_object.send("value=", new_date) if value_object
else
value_object.send("value=", value) if value_object
end
@save_attrs ||= []
@save_attrs << value_object
end
end
end
end
end
ActiveRecord::Base.send :include, ActiveRecord::Has::CustomFields