module CalculatedAttribute # :nodoc: def self.append_features(base) super base.extend(ClassMethods) end module ClassMethods # This ActiveRecord plugin provides a new class method, +calculated_attr+, that allows you to cache # the value of a block to a column in the database, and have the value automatically re-cached when # the model or its associations change. For example: # # class Invoice < ActiveRecord::Base # # has_many :line_items # has_many :payments # # # expects a 'subtotal' column in the 'invoices' table # calculated_attr :subtotal, :dependent => [ :line_items ] do # line_items.inject(0) { |sum, line| sum + line.total } # end # # # expects a 'paid' column in the 'invoices' table # calculated_attr :paid, :dependent => [ :payments ] do # payments.inject(0) { |sum, payment| sum + payment.total } # end # # # expects a 'total' column in the 'invoices' table # calculated_attr :total, :dependent => [ :line_items ] do # subtotal + tax # end # # # expects a 'due' column in the 'invoices' table # calculated_attr :due, :dependent => [ :line_items, :payments ] do # total - paid # end # # end # # i = Invoice.new; i.line_items << LineItem.new(:total => 10) # i.save # calculates the value of 'subtotal' and saves it to the invoices table # i = Invoice.find :first, :conditions => "subtotal=10" # i.subtotal # returns the cached value; no need to load line items # i.line_items << LineItem.new(:total => 5) # re-calculates and saves the subtotal # # Now, whenever the record is saved or validated, or associations are added or removed, the # attributes are re-calculated and saved in the attributes hash. From then on, the attribute # accessors use the cached values, rather than the expensive computation. Saving the model # causes the calculated attributes to be stored in the database, so they can be used in queries. # def calculated_attr(name, options={}, &calculation) send :include, InstanceMethods # Save the attribute name. class_inheritable_array :calculated_attributes self.calculated_attributes = [name] # Create a new instance method from the block. define_method "calculated_#{name}".to_sym, calculation private "calculated_#{name}".to_sym # Disable the attribute setter. define_method "#{name}=".to_sym do |value| # no-op end # Overwrite the attribute getter. If the cached value exists, return it; otherwise re-calculate. define_method name do send(:read_attribute, name) or send("recalculate_#{name}".to_sym) end # Re-calculate the attribute, but don't save. define_method "recalculate_#{name}".to_sym do send :write_attribute, name, send("calculated_#{name}".to_sym) send(:read_attribute, name) end private "recalculate_#{name}".to_sym # Nullify the attribute, but don't save. define_method "nullify_#{name}".to_sym do send :write_attribute, name, nil end private "nullify_#{name}".to_sym # Set up callback to re-calculate before validation (and hence before save). before_validation "recalculate_#{name}".to_sym # Set up after_add and after_remote association callbacks. options[:dependent].each do |association| add_association_callbacks association, :after_add => :recalculate_attributes!, :after_remove => :recalculate_attributes! end end end # When +calculated_attr+ is first used in a ActiveRecord class, these instance methods are added. # CalculatedAttribute keeps track of all calculated attributes that are defined, so that these # methods will affect all of them. The "!" methods indicate "dangerous", because they automatically # save the model, bypassing validation. module InstanceMethods # Nullify all calculated attributes. def nullify_attributes *args self.calculated_attributes.each do |col| send "nullify_#{col}" end end private :nullify_attributes # Nullify all calculated attributes and then save, skipping validation. def nullify_attributes! *args nullify_attributes and save_with_validation(false) end private :nullify_attributes! # Nullify and re-calculate all calculated attributes. def recalculate_attributes *args nullify_attributes self.calculated_attributes.each do |col| send "recalculate_#{col}" end end # Nullify and re-calculate all calculated attributes and then save, skipping validation. def recalculate_attributes! *args recalculate_attributes and save_with_validation(false) end end end