# ActiveRecord accessor methods are not really dynamic - rather they # dynamically get/set static properites as defined by the database. # # properties.rb is a layer on top of ActiveRecord which allows arbitrary named # and typed properties to be associated with any AR object in true dynamic # fashion. this is useful when arbitrary information needs to be associated # with an object or when rapid development needs to be done without the pain # of dicking with database evertime a property needs to be added to an object. # this means you can use something like the following on *any* model # # class Model < ActiveRecord::Base # properties( # :a => :float, # :b => :uri, # :c => { :value => 42, :cast => :integer } # ) # end # # m = Model.new # # p m.properties[:a] #=> nil # p m.properties[:b] #=> nil # p m.properties[:c] #=> 42 # # p m.a #=> nil # p m.b #=> nil # p m.c #=> 42 # # m.properties[:b] = 'http://codeforpeople.com' # # p m.properties[:b] #=> URI('http://codeforpeople.com') # # # the 'properties' class method will add a polymorphic has_many association # named 'property_records' to the target class. you shouldn't need to deal # with this association directly but you can if you think about what your're # doing. in general though, accessing that collection via the 'properties' # instance method will be much more convenient. for instance, that method # will ensure that any declared properties are created for each new or loaded # object - notice how all the properites :a, :b, and :c were pre-populated in # the above example. it will also transparently make sure properties are # either created or updated where appropriate. you can always add properties # to any object # # m.properties.add 'arbitrary' => :float # # m.properties.add 'foobar' => { :cast => :integer, :value => 42 } # # and these will be available on subsequent loads of the object. however, if # you set # # class Model # properties.strict true # end # # then *only* those properties declared at the class scope will be loaded and # any stray properties will be destroyed when the object is loaded. in # otherwords # # class Model < ActiveRecord::Base # properties( # :a => :float, # :b => :uri # ) # end # # means all Model objects will have *at least* :a and :b properites - you'll # be able to add arbitrary properties to individual model objects. while # # class Model < ActiveRecord::Base # properties( # :a => :float, # :b => :uri # ) # # properties.strict # end # # means that all models will have *only* those properties - no additional # onces may be added to instance records # # properites inherit to subclasses, and subclasses can add properties in the # POLS fashion # # class A < ActiveRecord::Base # properites :a => string # end # # class A < B # properites :b => string # end # # B.new.properties.keys #=> :a, :b # # you'll note that the properties container has a lot of hash-like methods # such as # # #values # #keys # #clear # #to_hash # #update # # and many more # # use properites when you require a variable collection of things to be # associated with a record, such as configuration information for # active_merchant gateways, dimensions to n-dimensional objects, etc. it's # useful anytime where objects need an arbitrary set of properites in addition # to the normal strict static set of boring columns and relationships. # # to get using properties.rb just drop it in your RAILS_ROOT/lib/ directory # and run the migration using # # ./script/runner ' Properties.migrate ' # # by default this uses a table name :properites, you can supply your own name # using # # ./script/runner ' Properties.table_name = :props and Properties.migrate ' # # if you choose to do this you'll also need to add # # Properites.table_name = :props # # somewhere in your code like config/environment.rb or config/initializers/* # # enjoy. # module ActiveRecord module Properties # the class level dsl for declaring properties # module ClassMethods # declare properties, return the map of properties for this class # hierarchy # def properties description = {} # setup the properites table for this and all subclasses # @@properties ||= Table.new # return it iff asked to # return @@properties if description == :table # bootstrap property instance methods based on descriptions # klass = self unless description.empty? if pair = @@properties.assoc(klass) pair.last.update description else @@properties << [klass, description] end description.each do |lhs, rhs| lhs = lhs.to_s case rhs when Hash rhs = rhs.to_options key = rhs[:key] || lhs else key = lhs end key = key.to_s code = <<-code def #{ key }() properties[#{ key.inspect }] end def #{ key }= value properties[#{ key.inspect }] = value end code module_eval code end code = <<-code def initialize_from_properties @attributes.reverse_merge! properties.to_hash end begin instance_method(:after_initialize) rescue Object def after_initialize(*args, &block) super if defined?(super) end end code module_eval code end # associate the property collection # has_many(:property_records, :as => :propertied, :class_name => Properties::Model.name, :dependent => :destroy, :uniq => true ) # make sure all new records will get properties, with their default # filled in # after_initialize :properties after_initialize :initialize_from_properties # add the properties collection wrapper method # include IntanceMethods return @@properties end alias_method :has_properties, :properties end # the instance level methods added on demand by the class level dsl # module IntanceMethods # wrap the property_records collection in a Map to manage the associated # records in a grok'able way # def properties options = {} options.to_options! if(options[:reload] or not defined?(@properties) or @properties.nil?) @properties = Map.for self end @properties end end # the associative array of klass -> properties for a class hierarchy # class Table < ::Array def strict= mode @strict = mode end def strict *mode self.strict = mode.first unless mode.empty? @strict end def for klass assoc klass end end # the wrapper for the collection that makes everything sugar and spice # class Map def Map.for object new object end def initialize object, options = {} @object = object initialize_from_class_properties! end def collection @object.property_records end def table @table ||= @object.class.properties(:table) end # bootstrap the object by walking down all appropriate initializers in the # class hierarchy - we'll end up with all properties from all superclasses # def initialize_from_class_properties! strict = table.strict table.strict = false table.each do |pair| # initialize from all superclasses # klass, description = pair next unless @object.class <= klass # make sure we have all the properties we should # allowed = [] description.each do |lhs, rhs| lhs = lhs.to_s allowed << lhs case rhs when Hash rhs = rhs.to_options create_unless_exists(rhs.update(:key => lhs)) else create_unless_exists({ :key => lhs, :cast => rhs.to_s }) end end # and none of the ones we should not iff we are being strict # if table.strict need_to_reload = false collection.each do |record| unless allowed.include?(record.key.to_s) record.destroy need_to_reload = true end end collection.reload if need_to_reload end end ensure table.strict = strict end # this let's you not worry if a property accociated record exists and # needs updated, or needs created - this abstraction lets you just add # them and nothing bad happens when they already exist # def create_unless_exists *args raise Error, "you cannot add properties (strict mode)" if table.strict args.map! do |arg| case arg when Hash arg.to_options! key = arg.delete :key value = arg.delete :value cast = arg.delete :cast if key.nil? key, cast = arg.to_a.first end { :key => key.to_s, :value => value, :cast => cast.to_s } else { :key => arg.to_s } end end records = [] msg = @object.new_record? ? 'build' : 'create' args.each do |hash| hash.to_options! key = hash[:key] record = nil unless has_property?(key) begin record = collection.send(msg, hash) records << record rescue Object raise unless has_property?(key) end end end records end alias_method 'add', 'create_unless_exists' def has_property? key collection.any?{|record| record.key.to_s == key.to_s} end def to_hash hash = HashWithIndifferentAccess.new collection.inject hash do |hash, record| hash.update record.key => record.value end end alias_method 'to_h', 'to_hash' def each &block collection.each do |record| block.call record.key, record.value end end alias_method 'each_pair', 'each' def map &block collection.map do |record| block.call record.key, record.value end end def map! &block collection.map do |record| key, value, *ignored = block.call record.key, record.value record.key = key record.value = value record end end def inspect to_hash.inspect end def clear collection.clear end def keys collection.map &:key end def has_key? key collection.detect{|r| r.key == key.to_s} end def values collection.map &:value end def to_a to_hash.to_a end def [] *keys values = keys.flatten.map do |key| key = key.to_s record = collection.detect{|record| record.key == key} raise ActiveRecord::RecordNotFound unless record record.value end ((keys.first.is_a?(Array) or keys.size > 1) ? values : values.first) end def update kvs = {} kvs.each do |key, value| key = key.to_s record = collection.detect{|record| record.key == key} raise ActiveRecord::RecordNotFound unless record record.value = value end self end def []= key, value update key => value value end end # the migration - copy something similar to your db/migrate dir if you like # class Migration < ActiveRecord::Migration def Migration.up create_table Properties.table_name, :force => true do |t| t.string :key; t.string :value; t.string :cast; t.references :propertied, :polymorphic => true end add_index Properties.table_name, [:propertied_type, :propertied_id, :key], :unique => true add_index Properties.table_name, [:propertied_type, :propertied_id] add_index Properties.table_name, [:propertied_type] end def Migration.down remove_index Properties.table_name, [:propertied_type] remove_index Properties.table_name, [:propertied_type, :propertied_id] remove_index Properties.table_name, [:propertied_type, :propertied_id, :key] drop_table Properties.table_name end end def Properties.table_name @table_name ||= 'properties' end def Properties.table_name= table_name @table_name = table_name end def Properties.migrate direction = 'up' Migration.send direction end def Properties.included other other.send :extend, ClassMethods end class Error < ::StandardError; end # the casts supported - note that you may add your own to the index - # anything that responds to 'call' will do # module Cast def for cast cast ||= 'string' cast = cast.to_s.downcase.strip if index[cast] index[cast] else index[cast] = begin method(cast).to_proc rescue Object raise Error, "no such cast #{ cast }" end end end def index @index ||= HashWithIndifferentAccess.new end def [] cast, callable index[cast] = callable end def integer value Float(value.to_s).to_i end def float value Float(value.to_s) end def boolean value case value.to_s when %r/^false|f|0/i false else true end end def maybe value case value.to_s when %r/^nil|unknown|maybe$/i nil when %r/^false|f|0/i false else true end end def date value require 'date' Date.parse value.to_s end def time value require 'time' Time.parse value.to_s end def datetime value require 'datetime' DateTime.parse value.to_s end def uri value require 'uri' URI.parse value.to_s end def symbol value value.to_s.to_sym end def string value value.to_s end extend self end # the polymorphically associated model - this stores all properties for all # tables. it's also responsible for applying casts to the values on # read/write # class Model < ::ActiveRecord::Base set_table_name Properties.table_name belongs_to :propertied, :polymorphic => true def value return nil if self['value'].to_s.empty? Cast.for(cast)[ self['value'] ] end def value= value self['value'] = Cast.for(cast)[ value ] end end end end # bless AR with the properties dsl # ActiveRecord::Base.send :include, ActiveRecord::Properties