├── VERSION ├── CHANGES.markdown ├── init.rb ├── .gitignore ├── Rakefile ├── MIT-LICENSE ├── statistics.gemspec ├── test └── statistics_test.rb ├── README.markdown └── lib └── statistics.rb /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /CHANGES.markdown: -------------------------------------------------------------------------------- 1 | Version 0.1, 27.05.09 - initial release -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'lib', 'statistics') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | *.swo 4 | .DS_Store 5 | debug.log 6 | coverage 7 | coverage.data 8 | pkg/ 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'jeweler' 3 | Jeweler::Tasks.new do |gemspec| 4 | gemspec.name = "statistics" 5 | gemspec.summary = "An ActiveRecord gem that makes it easier to do reporting." 6 | gemspec.email = "acatighera@gmail.com" 7 | gemspec.homepage = "http://github.com/acatighera/statistics" 8 | gemspec.authors = ["Alexandru Catighera"] 9 | end 10 | rescue LoadError 11 | puts "Jeweler not available. Install it with: sudo gem install jeweler" 12 | end 13 | 14 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Alexandru Catighera 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /statistics.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{statistics} 8 | s.version = "1.0.0" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Alexandru Catighera"] 12 | s.date = %q{2010-09-10} 13 | s.email = %q{acatighera@gmail.com} 14 | s.extra_rdoc_files = [ 15 | "README.markdown" 16 | ] 17 | s.files = [ 18 | ".gitignore", 19 | "CHANGES.markdown", 20 | "MIT-LICENSE", 21 | "README.markdown", 22 | "Rakefile", 23 | "VERSION", 24 | "init.rb", 25 | "lib/statistics.rb", 26 | "statistics.gemspec", 27 | "test/statistics_test.rb" 28 | ] 29 | s.homepage = %q{http://github.com/acatighera/statistics} 30 | s.rdoc_options = ["--charset=UTF-8"] 31 | s.require_paths = ["lib"] 32 | s.rubygems_version = %q{1.3.5} 33 | s.summary = %q{An ActiveRecord gem that makes it easier to do reporting.} 34 | s.test_files = [ 35 | "test/statistics_test.rb" 36 | ] 37 | 38 | if s.respond_to? :specification_version then 39 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 40 | s.specification_version = 3 41 | 42 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then 43 | else 44 | end 45 | else 46 | end 47 | end 48 | 49 | -------------------------------------------------------------------------------- /test/statistics_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | 3 | require 'rubygems' 4 | gem 'activerecord', '>= 1.15.4.7794' 5 | gem 'mocha', '>= 0.9.0' 6 | require 'active_record' 7 | require 'active_support' 8 | require 'mocha' 9 | 10 | require "#{File.dirname(__FILE__)}/../init" 11 | 12 | ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:") 13 | 14 | class Rails 15 | def self.cache 16 | ActiveSupport::Cache::MemCacheStore.new 17 | end 18 | end 19 | 20 | class StatisticsTest < Test::Unit::TestCase 21 | 22 | class BasicModel < ActiveRecord::Base 23 | define_statistic :basic_num, :count => :all 24 | end 25 | 26 | class MockModel < ActiveRecord::Base 27 | define_statistic "Basic Count", :count => :all 28 | define_statistic :symbol_count, :count => :all 29 | define_statistic "Basic Sum", :sum => :all, :column_name => 'amount' 30 | define_statistic "Chained Scope Count", :count => [:all, :named_scope] 31 | define_statistic "Default Filter", :count => :all 32 | define_statistic "Custom Filter", :count => :all, :filter_on => { :channel => 'channel = ?', :start_date => 'DATE(created_at) > ?', :blah => 'blah = ?' } 33 | define_statistic "Cached", :count => :all, :filter_on => { :channel => 'channel = ?', :blah => 'blah = ?' }, :cache_for => 1.second 34 | 35 | define_calculated_statistic "Total Amount" do 36 | defined_stats('Basic Sum') * defined_stats('Basic Count') 37 | end 38 | 39 | filter_all_stats_on(:user_id, "user_id = ?") 40 | end 41 | 42 | def test_basic 43 | BasicModel.expects(:basic_num_stat).returns(1) 44 | assert_equal({ :basic_num => 1 }, BasicModel.statistics) 45 | end 46 | 47 | def test_statistics 48 | MockModel.expects(:basic_count_stat).returns(2) 49 | MockModel.expects(:symbol_count_stat).returns(2) 50 | MockModel.expects(:basic_sum_stat).returns(27) 51 | MockModel.expects(:chained_scope_count_stat).returns(4) 52 | MockModel.expects(:default_filter_stat).returns(5) 53 | MockModel.expects(:custom_filter_stat).returns(3) 54 | MockModel.expects(:cached_stat).returns(9) 55 | MockModel.expects(:total_amount_stat).returns(54) 56 | 57 | ["Basic Count", 58 | :symbol_count, 59 | "Basic Sum", 60 | "Chained Scope Count", 61 | "Default Filter", 62 | "Custom Filter", 63 | "Cached", 64 | "Total Amount"].each do |key| 65 | assert MockModel.statistics_keys.include?(key) 66 | end 67 | 68 | assert_equal({ "Basic Count" => 2, 69 | :symbol_count => 2, 70 | "Basic Sum" => 27, 71 | "Chained Scope Count" => 4, 72 | "Default Filter" => 5, 73 | "Custom Filter" => 3, 74 | "Cached" => 9, 75 | "Total Amount" => 54 }, MockModel.statistics) 76 | end 77 | 78 | def test_get_stat 79 | MockModel.expects(:calculate).with(:count, :id, {}).returns(3) 80 | assert_equal 3, MockModel.get_stat("Basic Count") 81 | 82 | MockModel.expects(:calculate).with(:count, :id, { :conditions => "user_id = '54321'"}).returns(4) 83 | assert_equal 4, MockModel.get_stat("Basic Count", :user_id => 54321) 84 | end 85 | 86 | def test_basic_stat 87 | MockModel.expects(:calculate).with(:count, :id, {}).returns(3) 88 | assert_equal 3, MockModel.basic_count_stat({}) 89 | 90 | MockModel.expects(:calculate).with(:sum, 'amount', {}).returns(31) 91 | assert_equal 31, MockModel.basic_sum_stat({}) 92 | end 93 | 94 | def test_chained_scope_stat 95 | MockModel.expects(:all).returns(MockModel) 96 | MockModel.expects(:named_scope).returns(MockModel) 97 | MockModel.expects(:calculate).with(:count, :id, {}).returns(5) 98 | assert_equal 5, MockModel.chained_scope_count_stat({}) 99 | end 100 | 101 | def test_calculated_stat 102 | MockModel.expects(:basic_count_stat).returns(3) 103 | MockModel.expects(:basic_sum_stat).returns(33) 104 | 105 | assert_equal 99, MockModel.total_amount_stat({}) 106 | 107 | MockModel.expects(:basic_count_stat).with(:user_id => 5).returns(2) 108 | MockModel.expects(:basic_sum_stat).with(:user_id => 5).returns(25) 109 | 110 | assert_equal 50, MockModel.total_amount_stat({:user_id => 5}) 111 | 112 | MockModel.expects(:basic_count_stat).with(:user_id => 6).returns(3) 113 | MockModel.expects(:basic_sum_stat).with(:user_id => 6).returns(60) 114 | 115 | assert_equal 180, MockModel.total_amount_stat({:user_id => 6}) 116 | end 117 | 118 | def test_default_filter_stat 119 | MockModel.expects(:calculate).with(:count, :id, {}).returns(8) 120 | assert_equal 8, MockModel.default_filter_stat({}) 121 | 122 | MockModel.expects(:calculate).with(:count, :id, { :conditions => "user_id = '12345'" }).returns(2) 123 | assert_equal 2, MockModel.default_filter_stat( :user_id => '12345' ) 124 | end 125 | 126 | def test_custom_filter_stat 127 | MockModel.expects(:calculate).with(:count, :id, {}).returns(6) 128 | assert_equal 6, MockModel.custom_filter_stat({}) 129 | 130 | MockModel.expects(:calculate).with() do |param1, param2, param3| 131 | param1 == :count && 132 | param2 == :id && 133 | (param3 == { :conditions => "channel = 'chan5' AND DATE(created_at) > '#{Date.today.to_s(:db)}'" } || 134 | param3 == { :conditions => "DATE(created_at) > '#{Date.today.to_s(:db)}' AND channel = 'chan5'" } ) 135 | end.returns(3) 136 | assert_equal 3, MockModel.custom_filter_stat(:channel => 'chan5', :start_date => Date.today.to_s(:db)) 137 | end 138 | 139 | def test_cached_stat 140 | MockModel.expects(:calculate).returns(6) 141 | assert_equal 6, MockModel.cached_stat({:channel => 'chan5'}) 142 | 143 | MockModel.stubs(:calculate).returns(8) 144 | assert_equal 6, MockModel.cached_stat({:channel => 'chan5'}) 145 | assert_equal 8, MockModel.cached_stat({}) 146 | 147 | sleep(1) 148 | assert_equal 8, MockModel.cached_stat({:channel => 'chan5'}) 149 | end 150 | 151 | end 152 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Statistics 2 | 3 | This ActiverRecord plugin allows you to easily define and pull statistics for AR models. This plugin was built with reporting in mind. 4 | 5 | ## Announcements 6 | 7 | - Bug: There is a bug in Rails 2.x where grouping by multiple fields results in wrong values for calculations. I have created a patch for this bug, please encourage the fix by giving feedback or giving +1 plus a short supportive comment. [The patch lives here: https://rails.lighthouseapp.com/projects/8994/tickets/5182-activerecordcalculations-returns-incorrect-data-when-grouping-by-multiple-fields](https://rails.lighthouseapp.com/projects/8994/tickets/5182-activerecordcalculations-returns-incorrect-data-when-grouping-by-multiple-fields). 8 | 9 | ## Installation 10 | gem install statistics 11 | OR 12 | script/plugin install git://github.com/acatighera/statistics.git 13 | 14 | ## Examples 15 | #### Defining statistics is similar to defining named scopes. Strings and symbols both work as names. 16 | 17 | class Account < ActiveRecord::Base 18 | define_statistic :user_count, :count => :all 19 | define_statistic :average_age, :average => :all, :column_name => 'age' 20 | define_statistic 'subscriber count', :count => :all, :conditions => "subscription_opt_in = 1" 21 | end 22 | 23 | class Donations < ActiveRecord::Base 24 | define_statistic :total_donations, :sum => :all, :column_name => "amount" 25 | end 26 | 27 | #### Actually pulling the numbers is simple: 28 | 29 | #####for all stats 30 | 31 | Account.statistics # returns { :user_count => 120, :average_age => 28, 'subscriber count' => 74 } 32 | 33 | #####for a single stat 34 | 35 | Account.get_stat(:user_count) # returns 120 36 | 37 | ### Here are some additional benefits of using this plugin: 38 | 39 | #### Easily Filter 40 | 41 | Note: I found filtering to be an important part of reporting (ie. filtering by date). All filters are optional so even if you define them you don’t have to use them when pulling data. Using the `filter_all_stats_on` method and `:joins` options you can make things filterable by the same things which I found to be extremely useful. 42 | 43 | class Account < ActiveRecord::Base 44 | define_statistic :user_count, :count => :all, :filter_on => { :state => 'state = ?', :created_after => 'DATE(created_at) > ?'} 45 | define_statistic :subscriber_count, :count => :all, :conditions => "subscription_opt_in = true" 46 | 47 | filter_all_stats_on(:account_type, "account_type = ?") 48 | end 49 | 50 | Account.statistics(:account_type => 'non-admin') 51 | Account.get_stat(:user_count, :account_type => 'non-admin', :created_after => ‘2009-01-01’, :state => 'NY') 52 | 53 | # NOTE: filters are optional (ie. no filters will be applied if none are passed in) 54 | Account.get_stat(:user_count) 55 | 56 | #### Caching 57 | 58 | This is a new feature that uses `Rails.cache`. You can cache certain statistics for a specified amount of time (see below). By default caching is disabled if you do not pass in the `:cache_for` option. It is also important to note that caching is scoped by filters, there is no way around this since different filters produce different values. 59 | class Account < ActiveRecord::Base 60 | define_statistic :user_count, :count => :all, :cache_for => 30.minutes, :filter_on { :state => 'state = ?' } 61 | end 62 | 63 | Account.statistics(:state => 'NY') # This call generates a SQL query 64 | 65 | Account.statistics(:state => 'NY') # This call and subsequent calls for the next 30 minutes will use the cached value 66 | 67 | Account.statistics(:state => 'PA') # This call generates a SQL query because the user count for NY and PA could be different (and probably is) 68 | 69 | Note: If you want Rails.cache to work properly, you need to use mem_cache_store in your rails enviroment file (ie. `config.cache_store = :mem_cache_store` in your enviroment.rb file). 70 | 71 | #### Standardized 72 | 73 | All ActiveRecord classes now respond to `statistics` and `get_stat` methods 74 | 75 | all_stats = [] 76 | [ Account, Post, Comment ].each do |ar| 77 | all_stats << ar.statistics 78 | end 79 | 80 | #### Calculated statistics (DRY) 81 | 82 | You can define calculated metrics in order to perform mathematical calculations on one or more defined statistics. 83 | 84 | class Account < ActiveRecord::Base 85 | has_many :donations 86 | 87 | define_statistic :user_count, :count => :all 88 | define_statistic :total_donations, :sum => :all, :column_name => 'donations.amount', :joins => :donations 89 | 90 | define_calculated_statistic :average_donation_per_user do 91 | defined_stats(:total_donations) / defined_stats(:user_count) 92 | end 93 | 94 | filter_all_stats_on(:account_type, "account_type = ?") 95 | filter_all_stats_on(:state, "state = ?") 96 | filter_all_stats_on(:created_after, "DATE(created_at) > ?") 97 | end 98 | 99 | 100 | Pulling stats for calculated metrics is the same as for regular statistics. They also work with filters like regular statistics! 101 | 102 | Account.get_stat(:average_donation_per_user, :account_type => 'non-admin', :state => 'NY') 103 | Account.get_stat(:average_donation_per_user, :created_after => '2009-01-01') 104 | 105 | #### Reuse scopes you already have defined 106 | 107 | You can reuse the code you have written to do reporting. 108 | 109 | class Account < ActiveRecord::Base 110 | has_many :posts 111 | 112 | named_scope :not_admins, :conditions => “account_type = ‘non-admin’” 113 | named_scope :accounts_with_posts, :joins => :posts 114 | 115 | define_statistic :active_users_count, :count => [:not_admins, :accounts_with_posts] 116 | end 117 | 118 | #### Accepts all ActiveRecord::Calculations options 119 | 120 | The `:conditions` and `:joins` options are all particularly useful 121 | 122 | class Account < ActiveRecord::Base 123 | has_many :posts 124 | 125 | define_statistic :active_users_count, :count => :all, :joins => :posts, :conditions => "account_type = 'non-admin'" 126 | end 127 | 128 | ###### Copyright (c) 2009 Alexandru Catighera, released under MIT license 129 | -------------------------------------------------------------------------------- /lib/statistics.rb: -------------------------------------------------------------------------------- 1 | module Statistics 2 | class << self 3 | def included(base) 4 | base.extend(HasStats) 5 | end 6 | 7 | def default_filters(filters) 8 | ActiveRecord::Base.instance_eval { @filter_all_on = filters } 9 | end 10 | 11 | def supported_calculations 12 | [:average, :count, :maximum, :minimum, :sum] 13 | end 14 | end 15 | 16 | # This extension provides the ability to define statistics for reporting purposes 17 | module HasStats 18 | 19 | # OPTIONS: 20 | # 21 | #* +average+, +count+, +sum+, +maximum+, +minimum+ - Only one of these keys is passed, which 22 | # one depends on the type of operation. The value is an array of named scopes to scope the 23 | # operation by (+:all+ should be used if no scopes are to be applied) 24 | #* +column_name+ - The SQL column to perform the operation on (default: +id+) 25 | #* +filter_on+ - A hash with keys that represent filters. The with values in the has are rules 26 | # on how to generate the query for the correspond filter. 27 | #* +cached_for+ - A duration for how long to cache this specific statistic 28 | # 29 | # Additional options can also be passed in that would normally be passed to an ActiveRecord 30 | # +calculate+ call, like +conditions+, +joins+, etc 31 | # 32 | # EXAMPLE: 33 | # 34 | # class MockModel < ActiveRecord::Base 35 | # 36 | # named_scope :my_scope, :conditions => 'value > 5' 37 | # 38 | # define_statistic "Basic Count", :count => :all 39 | # define_statistic "Basic Sum", :sum => :all, :column_name => 'amount' 40 | # define_statistic "Chained Scope Count", :count => [:all, :my_scope] 41 | # define_statistic "Default Filter", :count => :all 42 | # define_statistic "Custom Filter", :count => :all, :filter_on => { :channel => 'channel = ?', :start_date => 'DATE(created_at) > ?' } 43 | # define_statistic "Cached", :count => :all, :filter_on => { :channel => 'channel = ?', :blah => 'blah = ?' }, :cache_for => 1.second 44 | # end 45 | def define_statistic(name, options) 46 | method_name = name.to_s.gsub(" ", "").underscore + "_stat" 47 | 48 | @statistics ||= {} 49 | @filter_all_on ||= ActiveRecord::Base.instance_eval { @filter_all_on } 50 | @statistics[name] = method_name 51 | 52 | options = { :column_name => :id }.merge(options) 53 | 54 | calculation = options.keys.find {|opt| Statistics::supported_calculations.include?(opt)} 55 | calculation ||= :count 56 | 57 | # We must use the metaclass here to metaprogrammatically define a class method 58 | (class< options[:cache_for]) if options[:cache_for] 90 | 91 | stat_value 92 | end 93 | end 94 | end 95 | 96 | # Defines a statistic using a block that has access to all other defined statistics 97 | # 98 | # EXAMPLE: 99 | # class MockModel < ActiveRecord::Base 100 | # define_statistic "Basic Count", :count => :all 101 | # define_statistic "Basic Sum", :sum => :all, :column_name => 'amount' 102 | # define_calculated_statistic "Total Profit" 103 | # defined_stats('Basic Sum') * defined_stats('Basic Count') 104 | # end 105 | def define_calculated_statistic(name, &block) 106 | method_name = name.to_s.gsub(" ", "").underscore + "_stat" 107 | 108 | @statistics ||= {} 109 | @statistics[name] = method_name 110 | 111 | (class< 'registered', :user_status => 'active') 130 | def statistics(filters = {}, except = nil) 131 | (@statistics || {}).inject({}) do |stats_hash, stat| 132 | stats_hash[stat.first] = send(stat.last, filters) if stat.last != except 133 | stats_hash 134 | end 135 | end 136 | 137 | # returns a single statistic based on the +stat_name+ paramater passed in and 138 | # similarly to the +statistics+ method, it also can take filters. 139 | # 140 | # EXAMPLE: 141 | # MockModel.get_stat('Basic Count') 142 | # MockModel.get_stat('Basic Count', :user_type => 'registered', :user_status => 'active') 143 | def get_stat(stat_name, filters = {}) 144 | send(@statistics[stat_name], filters) if @statistics[stat_name] 145 | end 146 | 147 | # to keep things DRY anything that all statistics need to be filterable by can be defined 148 | # seperatly using this method 149 | # 150 | # EXAMPLE: 151 | # 152 | # class MockModel < ActiveRecord::Base 153 | # define_statistic "Basic Count", :count => :all 154 | # define_statistic "Basic Sum", :sum => :all, :column_name => 'amount' 155 | # 156 | # filter_all_stats_on(:user_id, "user_id = ?") 157 | # end 158 | def filter_all_stats_on(name, cond) 159 | @filter_all_on ||= {} 160 | @filter_all_on[name] = cond 161 | end 162 | 163 | private 164 | 165 | def defined_stats(name) 166 | get_stat(name, @filters) 167 | end 168 | 169 | def sql_options(options) 170 | Statistics::supported_calculations.each do |deletable| 171 | options.delete(deletable) 172 | end 173 | options.delete(:column_name) 174 | options.delete(:filter_on) 175 | options.delete(:cache_for) 176 | options 177 | end 178 | end 179 | end 180 | 181 | ActiveRecord::Base.send(:include, Statistics) 182 | --------------------------------------------------------------------------------