module Cms module Behaviors # The DynamicAttributes behavior allows a model to store values for any attributes. # A model that uses DynamicAttributes should have corresponding "_attributes" table # where it stores the values for the dynamic attributes. # This is based on the {Flex Attributes Rails Plugin}[http://rubyforge.org/projects/flex-attributes]. # # class User < ActiveRecord::Base # has_dynamic_attributes # end # eric = User.find_by_login 'eric' # puts "My AOL instant message name is: #{eric.aim}" # eric.phone = '555-123-4567' # eric.save # # The above example should work even though "aim" and "phone" are not # attributes on the User model. # # The following options are available on for has_dynamic_attributes to modify # the behavior. Reasonable defaults are provided: # # 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 # fields:: # A list of fields that are valid dynamic attributes. By default # this is "nil" which means that all field are valid. Use this option if # you want some fields to go to one dynamic attribute model while other # fields will go to another. As an alternative you can override the # #dynamic_attributes method which will return a list of all valid dynamic # attributes. This is useful if you want to read the list of attributes # from another source to keep your code DRY. This method is given a # single argument which is the class for the related model. The following # provide an example: # # class User < ActiveRecord::Base # has_dynamic_attributes :class_name => 'UserContactInfo' # has_dynamic_attributes :class_name => 'Preferences' # # def dynamic_attributes(model) # case model # when UserContactInfo # %w(email phone aim yahoo msn) # when Preference # %w(project_search project_order user_search user_order) # else Array.new # end # end # end # # eric = User.find_by_login 'eric' # eric.email = 'eric@example.com' # Will save to UserContactInfo model # eric.project_order = 'name' # Will save to Preference # eric.save # Carries out save so now values are in database # # Note the else clause in our case statement. Since an empty array is # returned for all other models (perhaps added later) then we can be # certain that only the above dynamic attributes are allowed. # # If both a :fields option and #dynamic_attributes method is defined the # :fields option take precidence. This allows you to easily define the # field list inline for one model while implementing #dynamic_attributes # for another model and not having #dynamic_attributes need to determine # what model it is answering for. In both cases the list of dynamic # attributes can be a list of string or symbols # # A final alternative to :fields and #dynamic_attributes is the # #is_dynamic_attribute? method. This method is given two arguments. The # first is the attribute being retrieved/saved the second is the Model we # are testing for. If you override this method then the #dynamic_attributes # method or the :fields option will have no affect. Use of this method # is ideal when you want to retrict the attributes but do so in a # algorithmic way. The following is an example: # class User < ActiveRecord::Base # has_dynamic_attributes :class_name => 'UserContactInfo' # has_dynamic_attributes :class_name => 'Preferences' # # def is_dynamic_attribute?(attr, model) # case attr.to_s # when /^contact_/ then true # when /^preference_/ then true # else # false # end # end # end # # eric = User.find_by_login 'eric' # eric.contact_phone = '555-123-4567' # eric.contact_email = 'eric@example.com' # eric.preference_project_order = 'name' # eric.some_attribute = 'blah' # If some_attribute is not defined on # # the model then method not found is thrown module DynamicAttributes def self.included(model_class) model_class.extend(MacroMethods) end module MacroMethods def has_dynamic_attributes? !!@has_dynamic_attributes end # Will make the current class have dynamic attributes. def has_dynamic_attributes(options={}) @has_dynamic_attributes = true include InstanceMethods # Provide default options options[:class_name] ||= self.class_name + 'Attribute' options[:table_name] ||= options[:class_name].tableize options[:relationship_name] ||= options[:class_name].tableize.to_sym options[:foreign_key] ||= self.class_name.foreign_key options[:base_foreign_key] ||= self.name.underscore.foreign_key options[:name_field] ||= 'name' options[:value_field] ||= 'value' options[:fields].collect! {|f| f.to_s} unless options[:fields].nil? # Init option storage if necessary cattr_accessor :dynamic_options self.dynamic_options ||= Hash.new # Return if already processed. return if self.dynamic_options.keys.include? options[:class_name] # Attempt to load related class. If not create it begin options[:class_name].constantize rescue Object.const_set(options[:class_name], Class.new(ActiveRecord::Base)).class_eval do def self.reloadable? #:nodoc: false end end end # Store options self.dynamic_options[options[:class_name]] = options # Modify attribute class attribute_class = options[:class_name].constantize 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[:class_name], :table_name => options[:table_name], :foreign_key => options[:foreign_key], :dependent => :destroy # The following is only setup once unless private_method_defined? :method_missing_without_dynamic_attributes # Carry out delayed actions before save after_validation_on_update :save_modified_dynamic_attributes # Make attributes seem real alias_method :method_missing_without_dynamic_attributes, :method_missing alias_method :method_missing, :method_missing_with_dynamic_attributes private alias_method :read_attribute_without_dynamic_attributes, :read_attribute alias_method :read_attribute, :read_attribute_with_dynamic_attributes alias_method :write_attribute_without_dynamic_attributes, :write_attribute alias_method :write_attribute, :write_attribute_with_dynamic_attributes end end end end module InstanceMethods # Will determine if the given attribute is a dynamic attribute on the # given model. Override this in your class to provide custom logic if # the #dynamic_attributes method or the :fields option are not flexible # enough. If you override this method :fields and #dynamic_attributes will # not apply at all unless you implement them yourself. def is_dynamic_attribute?(attr, model) attr = attr.to_s return dynamic_options[model.name][:fields].include?(attr) unless dynamic_options[model.name][:fields].nil? return dynamic_attributes(model).collect {|f| f.to_s}.include?(attr) unless dynamic_attributes(model).nil? true end # Return a list of valid dynamic attributes for the given model. Return # nil if any field is allowed. If you want to say no field is allowed # then return an empty array. If you just have a static list the :fields # option is most likely easier. def dynamic_attributes(model); nil end private # Called after validation on update so that dynamic attributes behave # like normal attributes in the fact that the database is not touched # until save is called. def save_modified_dynamic_attributes return if @save_dynamic_attr.nil? @save_dynamic_attr.each do |s| model, attr_name = s related_attr = dynamic_related_attr model, attr_name unless related_attr.nil? if related_attr.value.nil? dynamic_related(model).delete related_attr else related_attr.save end end end @save_dynamic_attr = [] end # Overrides ActiveRecord::Base#read_attribute def read_attribute_with_dynamic_attributes(attr_name) attr_name = attr_name.to_s exec_if_related attr_name do |model| return nil if !@remove_dynamic_attr.nil? && @remove_dynamic_attr.any? do |r| r[0] == model && r[1] == attr_name end value_field = dynamic_options[model.name][:value_field] related_attr = dynamic_related_attr model, attr_name return nil if related_attr.nil? return related_attr.send(value_field) end read_attribute_without_dynamic_attributes(attr_name) end # Overrides ActiveRecord::Base#write_attribute def write_attribute_with_dynamic_attributes(attr_name, value) attr_name = attr_name.to_s exec_if_related attr_name do |model| value_field = dynamic_options[model.name][:value_field] @save_dynamic_attr ||= [] @save_dynamic_attr << [model, attr_name] related_attr = dynamic_related_attr(model, attr_name) if related_attr.nil? # Used to check for nil? but this caused validation # problems that are harder to solve. blank? is probably # not correct but it works well for now. unless value.blank? name_field = dynamic_options[model.name][:name_field] foreign_key = dynamic_options[model.name][:foreign_key] dynamic_related(model).build name_field => attr_name, value_field => value, foreign_key => self.id end return value else value_field = (value_field.to_s + '=').to_sym return related_attr.send(value_field, value) end end write_attribute_without_dynamic_attributes(attr_name, value) end # Implements dynamic-attributes as if real getter/setter methods # were defined. def method_missing_with_dynamic_attributes(method_id, *args, &block) begin method_missing_without_dynamic_attributes method_id, *args, &block rescue NoMethodError => e attr_name = method_id.to_s.sub(/\=$/, '') exec_if_related attr_name do |model| if method_id.to_s =~ /\=$/ return write_attribute_with_dynamic_attributes(attr_name, args[0]) else return read_attribute_with_dynamic_attributes(attr_name) end end raise e end end # Retrieve the related dynamic attribute object def dynamic_related_attr(model, attr) name_field = dynamic_options[model.name][:name_field] dynamic_related(model).to_a.find {|r| r.send(name_field) == attr} end # Retrieve the collection of related dynamic attributes def dynamic_related(model) relationship = dynamic_options[model.name][:relationship_name] send relationship end # Yield only if attr_name is a dynamic_attribute def exec_if_related(attr_name) return false if self.class.column_names.include? attr_name each_dynamic_relation do |model| if is_dynamic_attribute?(attr_name, model) yield model end end end # Yields for each dynamic relation. def each_dynamic_relation dynamic_options.keys.each {|kls| yield kls.constantize} end # Returns the options for the dynamic attributes def dynamic_options nonversioned_class(self.class).dynamic_options end # Will return the parent model if kls is a versioned class def nonversioned_class(kls) if kls.name =~ /\:\:Version$/ base_class = kls.name base_class.sub!(/\:\:Version$/, '') return base_class.constantize end kls end # This overrides the attributes= defined in ActiveRecord::Base # The only difference is that this doesn't check to see if the # model responds_to the method before sending it # This is needed for Rails 2.2 def attributes=(new_attributes, guard_protected_attributes = true) return if new_attributes.nil? attributes = new_attributes.dup attributes.stringify_keys! multi_parameter_attributes = [] attributes = remove_attributes_protected_from_mass_assignment(attributes) if guard_protected_attributes attributes.each do |k, v| if k.include?("(") multi_parameter_attributes << [ k, v ] else send(:"#{k}=", v) end end assign_multiparameter_attributes(multi_parameter_attributes) end end end end end