calculon 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ docs
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm --create ruby-1.9.2-p0@calculon
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in calculon.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Brian Muller
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Calculon
2
+
3
+ Calculon provides aggregate time functions for ActiveRecord.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'calculon'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```
22
+ $ gem install calculon
23
+ ```
24
+
25
+ ## Usage
26
+ Calculon allows you to group attributes using aggregate functions (sum, avg, min, max, etc) into time buckets (minute, hour, day, month, year). These buckets are "calendar" size - for instance, "by hour" means between absolute clock hours rather than a relative "within last 60 minutes, between 60-120 minutes ago, etc."
27
+
28
+ Let's say you have a Game model with two columns, one for Team A's points and the other for Team B's points.
29
+
30
+ ```ruby
31
+ class Game
32
+ attr_accessible :team_a_points, :team_b_points
33
+ end
34
+ ```
35
+
36
+ And now you want to know the total points for both teams by day:
37
+
38
+ ```ruby
39
+ Game.by_day(:team_a_points => :sum, :team_b_points => :sum)
40
+ ```
41
+
42
+ Awesome. Now let's say you want to know the average yesterday where Team A scored more than 0 points:
43
+
44
+ ```ruby
45
+ Game.by_day(:team_a_points => :avg, :team_b_points => :avg).on(Date.yesterday).where('team_a_points > 0')
46
+ ```
47
+
48
+ Now say you hate typing, and want to get points more easily:
49
+
50
+ ```ruby
51
+ class Game
52
+ calculon_view :points, :team_a_points => :sum, :team_b_points => :sum
53
+ end
54
+ ```
55
+
56
+ Now, you can get point sums more naturally:
57
+
58
+ ```ruby
59
+ Game.points_by_day.on(Date.yesterday)
60
+ Game.points_by_month.where('team_a_points > 0')
61
+ Game.points_by_year
62
+ ```
63
+
64
+ Let's say, however, that you want to know points by hour, but you want to get 24 results, regardless of whether or not a team scored (i.e., you want to fill in the "missing" hours):
65
+
66
+ ```ruby
67
+ nogame = OpenStruct.new(:team_a_points => 0, :team_b_points => 0)
68
+ Game.points_by_hour.on(Date.yesterday).to_filled_a(nogame)
69
+ ```
70
+
71
+ This will return an array of length 24, with "nogame" filling in each hour for which there was no game.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+ require 'rdoc/task'
4
+
5
+ Rake::TestTask.new("test") { |t|
6
+ t.libs += ["lib", "."]
7
+ t.test_files = FileList['test/*_test.rb']
8
+ t.verbose = true
9
+ }
10
+
11
+ task :default => [:test]
12
+
13
+ RDoc::Task.new("doc") { |rdoc|
14
+ rdoc.title = "Calculon - aggregate methods for models for Rails"
15
+ rdoc.rdoc_dir = 'docs'
16
+ rdoc.rdoc_files.include('README.md')
17
+ rdoc.rdoc_files.include('lib/**/*.rb')
18
+ }
data/calculon.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'calculon/version'
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.name = "calculon"
7
+ gem.version = Calculon::VERSION
8
+ gem.authors = ["Brian Muller"]
9
+ gem.email = ["bamuller@gmail.com"]
10
+ gem.description = %q{Calculon provides aggregate time functions for ActiveRecord.}
11
+ gem.summary = %q{Calculon provides aggregate time functions for ActiveRecord.}
12
+ gem.homepage = "https://github.com/opbandit/calculon"
13
+
14
+ gem.files = `git ls-files`.split($/)
15
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
16
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
17
+ gem.require_paths = ["lib"]
18
+ gem.add_dependency("rails", ">= 3.2.0")
19
+ gem.add_development_dependency('rdoc')
20
+ gem.add_development_dependency('rake')
21
+ gem.add_development_dependency('sqlite3')
22
+ gem.add_development_dependency('mysql2')
23
+ end
data/lib/calculon.rb ADDED
@@ -0,0 +1,115 @@
1
+ require "calculon/version"
2
+ require 'calculon/railtie'
3
+ require 'calculon/summarized_results'
4
+
5
+ module ActiveRecord
6
+ module QueryMethods
7
+ attr_accessor :calculon_opts
8
+ end
9
+
10
+ class Relation
11
+ def to_results_hash
12
+ if(calculon_opts.nil?)
13
+ raise "Not a Calculon Relation: You must call by_day/by_hour/etc before you can call to_hash"
14
+ end
15
+ Calculon::SummarizedResults.new(self)
16
+ end
17
+
18
+ def to_filled_a(default=nil)
19
+ to_results_hash.fill_missing!(default).to_a
20
+ end
21
+ end
22
+ end
23
+
24
+ module Calculon
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ end
28
+
29
+ module ClassMethods
30
+ def calculon_view(name, cols, opts=nil)
31
+ metaclass = class << self; self; end
32
+ metaclass.instance_eval do
33
+ [ "minute", "hour", "day", "month", "year" ].each do |window|
34
+ define_method("#{name}_by_#{window}") { |nopts=nil|
35
+ nopts = (opts || {}).merge(nopts || {})
36
+ send("by_#{window}".intern, cols, nopts)
37
+ }
38
+ end
39
+ end
40
+ end
41
+
42
+ def default_time_column(column)
43
+ @@calculon_time_column = column
44
+ end
45
+
46
+ def default_calculon_opts
47
+ @@calculon_time_column ||= "created_at"
48
+ { :time_column => @@calculon_time_column, :bycols => [] }
49
+ end
50
+
51
+ def on(date, opts=nil)
52
+ opts = default_calculon_opts.merge(opts || {})
53
+ raise "'on' method takes a Date object as the first param" unless date.is_a?(Date)
54
+ between date.to_time, date.to_time + 86399.seconds
55
+ end
56
+
57
+ def between(starttime, endtime, opts=nil)
58
+ opts = default_calculon_opts.merge(opts || {})
59
+ relation = where ["#{opts[:time_column]} >= ? and #{opts[:time_column]} <= ?", starttime, endtime]
60
+ relation.calculon_opts ||= {}
61
+ relation.calculon_opts.merge!(:starttime => starttime, :endtime => endtime)
62
+ relation
63
+ end
64
+
65
+ def by_minute(cols, opts=nil)
66
+ tcol = "concat(date(%{time_column}),' ',lpad(hour(%{time_column}),2,'0'),':',lpad(minute(%{time_column}),2,'0'),':00')"
67
+ by_bucket :minute, tcol, cols, opts
68
+ end
69
+
70
+ def by_hour(cols, opts=nil)
71
+ by_bucket :hour, "concat(date(%{time_column}),' ',lpad(hour(%{time_column}),2,'0'),':00:00')", cols, opts
72
+ end
73
+
74
+ def by_day(cols, opts=nil)
75
+ by_bucket :day, "concat(date(%{time_column}),' 00:00:00')", cols, opts
76
+ end
77
+
78
+ def by_month(cols, opts=nil)
79
+ by_bucket :month, "concat(year(%{time_column}),'-',lpad(month(%{time_column}),2,'0'),'-01 00:00:00')", cols, opts
80
+ end
81
+
82
+ def by_year(cols, opts=nil)
83
+ by_bucket :year, "concat(year(%{time_column}),'-01-01 00:00:00')", cols, opts
84
+ end
85
+
86
+ def by_bucket(bucket_name, bucket, cols, opts=nil)
87
+ opts = default_calculon_opts.merge(opts || {})
88
+
89
+ unless ActiveRecord::Base.connection.adapter_name == "Mysql2"
90
+ raise "Mysql2 is the only supported connection adapter for calculon"
91
+ end
92
+
93
+ # Set column in bucket string. This should be based on localtime, in case there are some points
94
+ # that fall on both sides of a date - so group by this conversion. The 'where' doesn't
95
+ # need this conversion because Rails does this automatically before calling the query
96
+ # (see http://api.rubyonrails.org/classes/ActiveRecord/Base.html#method-c-default_timezone)
97
+ time_column = "CONVERT_TZ(#{opts[:time_column]}, '+00:00', '#{Time.zone.formatted_offset}')"
98
+ bucket = bucket % { :time_column => time_column }
99
+
100
+ # if we're grouping by other columns, we need to select them
101
+ groupby = opts[:bycols] + ["time_bucket"]
102
+ opts[:bycols].each { |c| cols[c] = nil }
103
+ cols = cols.map { |name,method|
104
+ asname = name.to_s.gsub(' ', '').tr('^A-Za-z0-9', '_')
105
+ method.nil? ? name : "#{method}(#{name}) as #{asname}"
106
+ } + [ "#{bucket} as time_bucket" ]
107
+
108
+ relation = select(cols.join(",")).group(*groupby).order("time_bucket ASC")
109
+ relation.calculon_opts ||= {}
110
+ relation.calculon_opts.merge!(opts)
111
+ relation.calculon_opts[:bybucket] = bucket_name
112
+ relation
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,11 @@
1
+ module Calculon
2
+
3
+ class Railtie < Rails::Railtie
4
+ initializer 'calculon.insert_into_active_record' do
5
+ ActiveSupport.on_load :active_record do
6
+ ActiveRecord::Base.send(:include, Calculon)
7
+ end
8
+ end
9
+ end
10
+
11
+ end
@@ -0,0 +1,61 @@
1
+ module Calculon
2
+ class SummarizedResults < Hash
3
+ attr_reader :rows
4
+
5
+ def initialize(relation)
6
+ super({})
7
+
8
+ @bucket_size = relation.calculon_opts[:bybucket]
9
+ @grouped_by = relation.calculon_opts[:bycols] || []
10
+
11
+ @start_time = relation.calculon_opts[:starttime] || keys.sort.first
12
+ @start_time = @start_time.to_time if @start_time.is_a?(Date)
13
+
14
+ @end_time = relation.calculon_opts[:endtime] || keys.sort.last
15
+ @end_time = @end_time.to_time if @end_time.is_a?(Date)
16
+
17
+ relation.to_a.each { |row|
18
+ self[row.time_bucket] = row
19
+ }
20
+ end
21
+
22
+ def fill_missing!(value=nil)
23
+ each_time { |key|
24
+ self[key] = value unless has_key?(key)
25
+ }
26
+ self
27
+ end
28
+
29
+ def to_a
30
+ results = []
31
+ each_time { |key|
32
+ results << self[key] if self.has_key?(key)
33
+ }
34
+ results
35
+ end
36
+
37
+ def time_format
38
+ {
39
+ :minute => "%Y-%m-%d %H:%M:00",
40
+ :hour => "%Y-%m-%d %H:00:00",
41
+ :day => "%Y-%m-%d 00:00:00",
42
+ :month => "%Y-%m-01 00:00:00",
43
+ :year => "%Y-01-01 00:00:00"
44
+ }.fetch(@bucket_size)
45
+ end
46
+
47
+ def each_time
48
+ increment_amounts = { :minute => 1.minute, :hour => 1.hour, :day => 1.day, :month => 1.month, :year => 1.year }
49
+ increment = increment_amounts[@bucket_size]
50
+
51
+ # get the "floor" of the start and end times (the "floor" bucket)
52
+ current = Time.zone.parse(@start_time.strftime(time_format + " %z"))
53
+ last_time = Time.zone.parse(@end_time.strftime(time_format + " %z"))
54
+
55
+ while current <= last_time
56
+ yield current.strftime(time_format)
57
+ current += increment
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module Calculon
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,64 @@
1
+ require 'test/unit'
2
+ require 'rails/all'
3
+ require 'calculon'
4
+
5
+ ActiveRecord::Base.establish_connection adapter: "mysql2", database: "calculon_test", username: 'root'
6
+ ActiveRecord::Base.default_timezone = :utc
7
+ Time.zone = "UTC"
8
+
9
+ class Game < ActiveRecord::Base
10
+ include Calculon
11
+ calculon_view :points, :team_a_points => :sum, :team_b_points => :sum
12
+ end
13
+
14
+ class CalculonTest < Test::Unit::TestCase
15
+ def setup
16
+ old = $stdout
17
+ $stdout = StringIO.new
18
+ ActiveRecord::Base.logger
19
+ ActiveRecord::Schema.define(version: 1) do
20
+ create_table :games do |t|
21
+ t.column :team_a_points, :integer, default: 0
22
+ t.column :team_b_points, :integer, default: 0
23
+ t.timestamps
24
+ end
25
+ end
26
+ $stdout = old
27
+ end
28
+
29
+ def teardown
30
+ ActiveRecord::Base.connection.tables.each do |table|
31
+ ActiveRecord::Base.connection.drop_table(table)
32
+ end
33
+ end
34
+
35
+ def test_results_hash
36
+ Game.create(:team_a_points => 10, :team_b_points => 20, :created_at => 33.hours.ago)
37
+ Game.create(:team_a_points => 30, :team_b_points => 40, :created_at => 2.hours.ago)
38
+
39
+ assert_equal Game.by_hour(:team_a_points => :sum).length, 2
40
+ results = Game.points_by_hour.to_results_hash
41
+ keys = [33.hours.ago.strftime("%Y-%m-%d %H:00:00"), 2.hours.ago.strftime("%Y-%m-%d %H:00:00")]
42
+ assert_equal keys, results.keys.sort
43
+ assert_equal results[keys.first].team_a_points, 10
44
+ assert_equal results[keys.last].team_b_points, 40
45
+
46
+ assert_equal Game.by_day(:team_a_points => :sum).length, 2
47
+ results = Game.points_by_day.to_results_hash
48
+ keys = [33.hours.ago.strftime("%Y-%m-%d 00:00:00"), 2.hours.ago.strftime("%Y-%m-%d 00:00:00")]
49
+ assert_equal keys, results.keys.sort
50
+ assert_equal results[keys.first].team_a_points, 10
51
+ assert_equal results[keys.last].team_b_points, 40
52
+ end
53
+
54
+ def test_results_hash_missing
55
+ Game.create(:team_a_points => 10, :created_at => Time.zone.now - 0.hours)
56
+ Game.create(:team_a_points => 20, :created_at => Time.zone.now - 1.hours)
57
+ Game.create(:team_a_points => 30, :created_at => Time.zone.now - 2.hours)
58
+ Game.create(:team_a_points => 40, :created_at => Time.zone.now - 25.hours)
59
+
60
+ days = Game.points_by_day.to_a
61
+ assert_equal days.length, 2
62
+ assert_equal days.inject(0) { |s,g| s + g.team_a_points }, 100
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calculon
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.1
6
+ platform: ruby
7
+ authors:
8
+ - Brian Muller
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2013-04-21 00:00:00 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rails
17
+ requirement: &id001 !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 3.2.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: *id001
26
+ - !ruby/object:Gem::Dependency
27
+ name: rdoc
28
+ requirement: &id002 !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: *id002
37
+ - !ruby/object:Gem::Dependency
38
+ name: rake
39
+ requirement: &id003 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: "0"
45
+ type: :development
46
+ prerelease: false
47
+ version_requirements: *id003
48
+ - !ruby/object:Gem::Dependency
49
+ name: sqlite3
50
+ requirement: &id004 !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: "0"
56
+ type: :development
57
+ prerelease: false
58
+ version_requirements: *id004
59
+ - !ruby/object:Gem::Dependency
60
+ name: mysql2
61
+ requirement: &id005 !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: *id005
70
+ description: Calculon provides aggregate time functions for ActiveRecord.
71
+ email:
72
+ - bamuller@gmail.com
73
+ executables: []
74
+
75
+ extensions: []
76
+
77
+ extra_rdoc_files: []
78
+
79
+ files:
80
+ - .gitignore
81
+ - .rvmrc
82
+ - Gemfile
83
+ - LICENSE.txt
84
+ - README.md
85
+ - Rakefile
86
+ - calculon.gemspec
87
+ - lib/calculon.rb
88
+ - lib/calculon/railtie.rb
89
+ - lib/calculon/summarized_results.rb
90
+ - lib/calculon/version.rb
91
+ - test/calculon_test.rb
92
+ homepage: https://github.com/opbandit/calculon
93
+ licenses: []
94
+
95
+ post_install_message:
96
+ rdoc_options: []
97
+
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ none: false
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ hash: 611864359157079367
106
+ segments:
107
+ - 0
108
+ version: "0"
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ none: false
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ hash: 611864359157079367
115
+ segments:
116
+ - 0
117
+ version: "0"
118
+ requirements: []
119
+
120
+ rubyforge_project:
121
+ rubygems_version: 1.8.24
122
+ signing_key:
123
+ specification_version: 3
124
+ summary: Calculon provides aggregate time functions for ActiveRecord.
125
+ test_files:
126
+ - test/calculon_test.rb