Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add ActiveRecord::CalculatedAttribute.

  • Loading branch information...
commit c544d4ba2a51e03630d77d1cdc5570bd9c51b7b5 1 parent e3306e9
Jason Whittle authored
18 Gemfile.lock
View
@@ -6,6 +6,19 @@ PATH
GEM
remote: http://rubygems.org/
specs:
+ activemodel (3.1.3)
+ activesupport (= 3.1.3)
+ builder (~> 3.0.0)
+ i18n (~> 0.6)
+ activerecord (3.1.3)
+ activemodel (= 3.1.3)
+ activesupport (= 3.1.3)
+ arel (~> 2.2.1)
+ tzinfo (~> 0.3.29)
+ activesupport (3.1.3)
+ multi_json (~> 1.0)
+ arel (2.2.1)
+ builder (3.0.0)
columnize (0.3.6)
diff-lcs (1.1.3)
faker (1.0.1)
@@ -22,6 +35,7 @@ GEM
i18n (0.6.0)
linecache (0.46)
rbx-require-relative (> 0.0.4)
+ multi_json (1.0.4)
rb-fsevent (0.4.3.1)
rbx-require-relative (0.0.5)
rr (1.0.4)
@@ -33,12 +47,15 @@ GEM
ruby-debug-base (~> 0.10.4.0)
ruby-debug-base (0.10.4)
linecache (>= 0.3)
+ sqlite3 (1.3.5)
thor (0.14.6)
+ tzinfo (0.3.31)
PLATFORMS
ruby
DEPENDENCIES
+ activerecord
faker
functional!
guard
@@ -49,3 +66,4 @@ DEPENDENCIES
rspec-core
rspec-expectations
ruby-debug
+ sqlite3
4 functional.gemspec
View
@@ -25,4 +25,8 @@ Gem::Specification.new do |s|
s.add_development_dependency 'rb-fsevent'
s.add_development_dependency 'guard-bundler'
s.add_development_dependency 'guard-rspec'
+
+ # Used only for testing specific extensions.
+ s.add_development_dependency 'activerecord'
+ s.add_development_dependency 'sqlite3'
end
3  lib/functional.rb
View
@@ -1,2 +1 @@
-raise NotImplementedError, 'In order to use the functional library,'
- 'either require functional/all or the individual extensions desired.'
+raise NotImplementedError, 'In order to use the functional library, either require functional/all or the individual extensions desired.'
1  lib/functional/active_record.rb
View
@@ -0,0 +1 @@
+require 'functional/active_record/calculated_attribute'
97 lib/functional/active_record/calculated_attribute.rb
View
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+if defined? ActiveRecord
+ require 'active_support/core_ext/module/delegation'
+
+ module ActiveRecord
+ module CalculatedAttribute
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Link an attribute and a calculation in your model. This
+ # method is the entirety of the public interface to this module.
+ #
+ # Examples:
+ #
+ # calculated_attribute :reason do |email|
+ # if email.cause.respond_to? :reason
+ # email.cause.reason
+ # end
+ # end
+ #
+ # calc_attr(:action_at) { 3.days.from_now }
+ #
+ def calculated_attribute(name, &block)
+ calculator_factory.add_calculation name, &block
+ define_accessor name unless method_defined? name
+ chain_calculation_method name
+ end
+ alias_method :calc_attr, :calculated_attribute
+
+ # Each model class has its own CalculatorFactory instance, in
+ # which it aggregates the attribute-calculation pairs.
+ def calculator_factory(calculations = {})
+ @calculator_factory ||= CalculatorFactory.new(calculations)
+ end
+
+ # Define an accessor for attributes, so they can be chained.
+ def define_accessor(name)
+ define_method(name) { read_attribute name }
+ end
+
+ # Chain the accessor the ActiveSupport way.
+ def chain_calculation_method(name)
+ define_method "#{name}_with_calculation" do
+ calculate name, send("#{name}_without_calculation")
+ end
+ alias_method_chain name, :calculation
+ end
+ end
+
+ class CalculatorFactory < Struct.new :calculations
+ def add_calculation(name, &block)
+ calculations[name] = block
+ end
+
+ # Note that this is an instance method, and so does not override
+ # the class method ::new.
+ def new(model_instance)
+ Calculator.new model_instance, calculations
+ end
+ end
+
+ module InstanceMethods
+ # Each model instance has its own Calculator instance.
+ def calculator
+ @calculator ||= self.class.calculator_factory.new self
+ end
+ delegate :calculate, :to => :calculator
+ end
+
+ class Calculator < Struct.new :model, :calculations
+ # The model may implement an #automatic? method to get
+ # calculations. Otherwise, it is assumed that all defined
+ # calculations should be run.
+ def automatic?
+ !model.respond_to?(:automatic?) || model.automatic?
+ end
+
+ # Don’t calculate over supplied values.
+ def calculate?(value)
+ automatic? && value.nil?
+ end
+
+ # The calculate method has the intentional side-effect of
+ # assigning to the field, so that the calculation will be
+ # persisted.
+ def calculate(name, value)
+ return value unless calculate? value
+ model.send "#{name}=", perform_calculation(calculations[name])
+ end
+
+ def perform_calculation(proc)
+ proc.arity == 1 ? proc.call(model) : model.instance_eval(&proc)
+ end
+ end
+ end
+ end
+end
1  lib/functional/all.rb
View
@@ -1,2 +1,3 @@
+require 'functional/active_record'
require 'functional/array'
require 'functional/big_decimal'
70 spec/active_record/calculated_attribute_spec.rb
View
@@ -0,0 +1,70 @@
+require 'active_record'
+require 'functional/active_record/calculated_attribute'
+require 'spec_helper'
+
+ActiveRecord::Base.establish_connection $test_db
+ActiveRecord::Migration.verbose = false
+
+module ActiveRecord
+ class ::Fake < Base
+ def self.schema
+ lambda do |t|
+ t.boolean :automatic
+ t.integer :foo
+ t.integer :bar
+ t.integer :baz
+ t.references :other_fake
+ end
+ end
+
+ include CalculatedAttribute
+ calculated_attribute(:foo) { 2 }
+ calc_attr(:bar) { |fake| fake.foo + 3 }
+ calc_attr(:baz) { bar / 5 }
+ belongs_to :other_fake
+ calculated_attribute(:other_fake) { OtherFake.new :qux => 8 }
+ end
+
+ class ::OtherFake < Base
+ def self.schema
+ lambda { |t| t.integer :qux }
+ end
+
+ include CalculatedAttribute
+ end
+
+ describe CalculatedAttribute do
+ before(:all) { Migration.create_table :fakes, :force => true, &Fake.schema }
+ before(:all) { Migration.create_table :other_fakes, :force => true, &OtherFake.schema }
+ after(:all) { Migration.drop_table :fakes }
+ after(:all) { Migration.drop_table :other_fakes }
+
+ context 'when manual' do
+ subject { Fake.new :automatic => false }
+ its(:foo) { should be_nil }
+ its(:bar) { should be_nil }
+ its(:other_fake) { should be_nil }
+ end
+
+ context 'when automatic' do
+ subject { Fake.new :automatic => true }
+ its(:automatic) { should be_true }
+ its(:foo) { should == 2 }
+ its(:bar) { should == 5 }
+ its(:baz) { should == 1 }
+ it { subject.other_fake.qux.should == 8 }
+
+ context 'with foo already set' do
+ before { subject.foo = 13 }
+ its(:foo) { should == 13 }
+ its(:bar) { should == 16 }
+ its(:baz) { should == 3 }
+ end
+
+ it 'should store the attribute' do
+ subject.foo.should == 2
+ subject.attributes.symbolize_keys[:foo].should == 2
+ end
+ end
+ end
+end
8 spec/spec_helper.rb
View
@@ -2,6 +2,7 @@
require 'rspec/expectations'
require 'rr'
require 'faker'
+require File.expand_path '../support', __FILE__
begin
require 'ruby-debug'
@@ -10,9 +11,16 @@
$debugger = false
end
+$test_db = {
+ :adapter => 'sqlite3',
+ :database => ENV['TEST_DB'] || sandbox('test_db.sqlite3')
+}
+
RSpec.configure do |config|
config.mock_with :rr
+ config.before(:all) { create_sandbox }
config.around(:each) { |ex| Debugger.start &ex } if $debugger
+ config.after(:all) { clean_sandbox }
end
RSpec::Matchers.module_eval { alias_method :expects, :expect }
1  spec/support.rb
View
@@ -0,0 +1 @@
+Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
12 spec/support/sandbox.rb
View
@@ -0,0 +1,12 @@
+def sandbox(file = nil)
+ File.expand_path "../../sandbox/#{file}", __FILE__
+end
+
+def create_sandbox
+ Dir.mkdir sandbox unless File.exists? sandbox
+end
+
+def clean_sandbox
+ Dir.glob(sandbox('*')).each &File.method(:unlink)
+ Dir.unlink sandbox
+end
Please sign in to comment.
Something went wrong with that request. Please try again.