Skip to content

Commit

Permalink
Add #tracks_changes_over_time to ActiveRecord::Base
Browse files Browse the repository at this point in the history
  • Loading branch information
joelind committed Mar 12, 2011
1 parent ee65def commit cec4216
Show file tree
Hide file tree
Showing 17 changed files with 360 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
source 'http://rubygems.org'
gemspec

gem 'activerecord', '2.3.11'
33 changes: 33 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
PATH
remote: .
specs:
delta_force (0.0.1)
activerecord (~> 2.3.0)

GEM
remote: http://rubygems.org/
specs:
activerecord (2.3.11)
activesupport (= 2.3.11)
activesupport (2.3.11)
columnize (0.3.2)
factory_girl (1.3.3)
linecache (0.43)
pg (0.10.1)
ruby-debug (0.10.4)
columnize (>= 0.1)
ruby-debug-base (~> 0.10.4.0)
ruby-debug-base (0.10.4)
linecache (>= 0.3)
shoulda (2.11.3)

PLATFORMS
ruby

DEPENDENCIES
activerecord (= 2.3.11)
delta_force!
factory_girl
pg
ruby-debug
shoulda
Empty file added LICENSE
Empty file.
Empty file added README.rdoc
Empty file.
11 changes: 11 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require 'bundler'
#require 'rspec/core/rake_task'
require 'rake/testtask'

Bundler::GemHelper.install_tasks

Rake::TestTask.new(:test) do |test|
test.libs << 'test'
end

task :default => :test
37 changes: 37 additions & 0 deletions delta_force.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "delta_force/version"

Gem::Specification.new do |s|
s.name = "delta_force"
s.version = DeltaForce::VERSION
s.platform = Gem::Platform::RUBY
s.authors = ["Joe Lind"]
s.email = ["joelind@gmail.com"]
s.homepage = "http://github.com/joelind/delta_force"
s.summary = %q{ActiveRecord named scopes with Postgres window functions.}
s.description = %q{
DeltaForce lets you use Postgres 8.4+ window functions with ActiveRecord
}
s.post_install_message = %q{
*** Thanks for installing DeltaForce! ***
}

s.extra_rdoc_files = [
"LICENSE",
"README.rdoc"
]

s.rubyforge_project = "delta_force"

s.add_dependency 'activerecord', '~> 2.3.0'
s.add_development_dependency 'shoulda'
s.add_development_dependency 'factory_girl'
s.add_development_dependency 'ruby-debug'
s.add_development_dependency 'pg'

s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]
end
6 changes: 6 additions & 0 deletions lib/delta_force.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require 'delta_force/class_methods'

module DeltaForce
end

ActiveRecord::Base.extend DeltaForce::ClassMethods
97 changes: 97 additions & 0 deletions lib/delta_force/change_proxy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@

module DeltaForce
class ChangeProxy

attr_reader :klass

def initialize(klass)
@klass = klass
end

def values(*value_fields)
options = value_fields.extract_options!.symbolize_keys

partition_column = "#{table_name}.#{options[:by].to_s}"
id_column = "#{table_name}.id"

period_field_name = options[:over].to_s
period_column = "#{table_name}.#{period_field_name}"

scope_name = options[:scope_name] || default_scope_name(value_fields, options)

window = "
(
PARTITION BY #{partition_column}
ORDER BY #{period_column} DESC, #{id_column} DESC ROWS
BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)"

value_expressions = value_fields.collect do |value_field|
value_field_name = value_field.to_s
value_column = "#{table_name}.#{value_field_name}"

[
"last_value(#{value_column}) over #{window} as opening_#{value_field_name}",
"first_value(#{value_column}) over #{window} as closing_#{value_field_name}"
]
end.flatten

klass.named_scope scope_name, :select => "distinct #{partition_column},
last_value(#{period_column}) over #{window} as opening_#{period_field_name},
first_value(#{period_column}) over #{window} as closing_#{period_field_name},
#{value_expressions.join ','}
"
end

alias :value :values

private

def default_scope_name(value_fields, options)
value_fields = value_fields.map(&:to_s).sort
period = options[:over].to_s
partition = options[:by].to_s

value_fields = value_fields.to_sentence(:words_connector => '_and_',
:two_words_connector => '_and_',
:last_word_connector => '_and_')

"changes_in_#{value_fields}_by_#{partition}_over_#{period}"
end

def table_name
klass.table_name
end

def calculate_changes(value_field_name, options = {})
options = options.symbolize_keys

value_field_name = value_field_name.to_s

partition_field_name = options[:partition_by]
partition_column = "#{table_name}.#{partition_field_name.to_s}"

value_column = "#{table_name}.#{value_field_name}"
id_column = "#{table_name}.id"

period_field_name = options[:period] || 'period'
period_column = "#{table_name}.#{period_field_name}"

scope_name = "changes_in_#{value_field_name}".to_sym

window = "
(
PARTITION BY #{partition_column}
ORDER BY #{period_column} DESC, #{id_column} DESC ROWS
BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)"

named_scope scope_name, :select => "distinct #{partition_column},
last_value(#{period_column}) over #{window} as opening_#{period_field_name},
first_value(#{period_column}) over #{window} as closing_#{period_field_name},
last_value(#{value_column}) over #{window} as opening_#{value_field_name},
first_value(#{value_column}) over #{window} as closing_#{value_field_name}
"
end
end
end
41 changes: 41 additions & 0 deletions lib/delta_force/class_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require 'delta_force/change_proxy'

module DeltaForce
module ClassMethods

def tracks_changes_over_time
yield DeltaForce::ChangeProxy.new(self)
end

def calculates_changes_in(value_field_name, options = {})
options = options.symbolize_keys

value_field_name = value_field_name.to_s

partition_field_name = options[:partition_by]
partition_column = "#{table_name}.#{partition_field_name.to_s}"

value_column = "#{table_name}.#{value_field_name}"
id_column = "#{table_name}.id"

period_field_name = options[:period] || 'period'
period_column = "#{table_name}.#{period_field_name}"

scope_name = "changes_in_#{value_field_name}".to_sym

window = "
(
PARTITION BY #{partition_column}
ORDER BY #{period_column} DESC, #{id_column} DESC ROWS
BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)"

named_scope scope_name, :select => "distinct #{partition_column},
last_value(#{period_column}) over #{window} as opening_#{period_field_name},
first_value(#{period_column}) over#{window} as closing_#{period_field_name},
last_value(#{value_column}) over #{window} as opening_#{value_field_name},
first_value(#{value_column}) over #{window} as closing_#{value_field_name}
"
end
end
end
3 changes: 3 additions & 0 deletions lib/delta_force/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module DeltaForce
VERSION = "0.0.1"
end
2 changes: 2 additions & 0 deletions test/factories/bar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Factory.define :bar do |bar|
end
7 changes: 7 additions & 0 deletions test/factories/foo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Factory.define :foo do |foo|
foo.sequence(:period) { |n| n.days.ago.to_date }
foo.association(:bar)
foo.sequence(:x) { |n| n }
foo.sequence(:y) { |n| 10 * n }
foo.sequence(:z) { |n| 100 * n }
end
3 changes: 3 additions & 0 deletions test/fixtures/bar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Bar < ActiveRecord::Base
has_many :foos
end
3 changes: 3 additions & 0 deletions test/fixtures/foo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Foo < ActiveRecord::Base
belongs_to :bar
end
12 changes: 12 additions & 0 deletions test/fixtures/schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ActiveRecord::Schema.define do

create_table "bars", :force => true

create_table "foos", :force => true do |t|
t.integer "bar_id"
t.float "x"
t.float "y"
t.float "z"
t.date "period"
end
end
30 changes: 30 additions & 0 deletions test/helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require "rubygems"
require "bundler"
Bundler.setup
require 'test/unit'
require 'shoulda'
require 'factory_girl'
require 'active_record'
require 'delta_force'

FIXTURES_PATH = File.join(File.dirname(__FILE__), 'fixtures')

ActiveRecord::Base.establish_connection(
:adapter => 'postgresql',
:database => 'delta_force_test'
)

dep = defined?(ActiveSupport::Dependencies) ? ActiveSupport::Dependencies : ::Dependencies
dep.autoload_paths.unshift FIXTURES_PATH

ActiveRecord::Base.silence do
ActiveRecord::Migration.verbose = false
load File.join(FIXTURES_PATH, 'schema.rb')
end

Dir[File.expand_path(File.dirname(__FILE__)) + "/factories/*.rb"].each do |file|
require file
end

class Test::Unit::TestCase
end
71 changes: 71 additions & 0 deletions test/test_active_record_integration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require 'helper'

class TestActiveRecordIntegration < Test::Unit::TestCase
context 'tracks_changes_over_time' do
setup do
Foo.tracks_changes_over_time do |changes|
changes.values :x, :y, :z, :over => :period, :by => :bar_id
end
end

should 'add a changes_in_* named scope' do
assert Foo.respond_to?(:changes_in_x_and_y_and_z_by_bar_id_over_period)
end

context 'with foos for a given bar' do
setup do
@bar = Factory(:bar)

@foos = 3.times.collect do |i|
2.times.collect do
Factory :foo, :bar => @bar, :period => i.days.from_now.to_date,
:x=> (10 * i), :y => (100 * i), :z => (1000 * i)
end
end.flatten

#decoy foo
Factory :foo
end

context 'bar.foos.changes_in_x_and_y_and_z_by_bar_id_over_period' do
subject { @bar.foos.changes_in_x_and_y_and_z_by_bar_id_over_period }

should 'return a scope containig one object' do
assert_equal 1, subject.all.size
end

should 'return an object with opening_period' do
assert_equal @foos.first.period, subject.first.opening_period.to_date
end

should 'return an object with closing_period' do
assert_equal @foos.last.period, subject.first.closing_period.to_date
end

should 'return an object with closing_x' do
assert_equal @foos.last.x, subject.first.closing_x.to_f
end

should 'return an object with opening_x' do
assert_equal @foos.first.x, subject.first.opening_x.to_f
end

should 'return an object with closing_y' do
assert_equal @foos.last.y, subject.first.closing_y.to_f
end

should 'return an object with opening_y' do
assert_equal @foos.first.y, subject.first.opening_y.to_f
end

should 'return an object with closing_z' do
assert_equal @foos.last.z, subject.first.closing_z.to_f
end

should 'return an object with opening_z' do
assert_equal @foos.first.z, subject.first.opening_z.to_f
end
end
end
end
end

0 comments on commit cec4216

Please sign in to comment.