Skip to content
This repository
Browse code

Added calculations: Base.count, Base.average, Base.sum, Base.minimum,…

… Base.maxmium, and the generic Base.calculate. All can be used with :group and :having. Calculations and statitics need no longer require custom SQL. #3958 [Rick Olson]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@3646 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
commit 99307b959b1c16ab1dd0400a7398019fe9d5494b 1 parent 1aea470
David Heinemeier Hansson authored February 25, 2006
7  activerecord/CHANGELOG
... ...
@@ -1,5 +1,12 @@
1 1
 *SVN*
2 2
 
  3
+* Added calculations: Base.count, Base.average, Base.sum, Base.minimum, Base.maxmium, and the generic Base.calculate. All can be used with :group and :having. Calculations and statitics need no longer require custom SQL. #3958 [Rick Olson]. Examples:
  4
+
  5
+    Person.average :age
  6
+    Person.minimum :age
  7
+    Person.maximum :age
  8
+    Person.sum :salary, :group => :last_name
  9
+
3 10
 * Renamed Errors#count to Errors#size but kept an alias for the old name (and included an alias for length too) #3920 [contact@lukeredpath.co.uk]
4 11
 
5 12
 * Reflections don't attempt to resolve module nesting of association classes. Simplify type computation. [Jeremy Kemper]
2  activerecord/lib/active_record.rb
@@ -49,6 +49,7 @@
49 49
 require 'active_record/locking'
50 50
 require 'active_record/migration'
51 51
 require 'active_record/schema'
  52
+require 'active_record/calculations'
52 53
 
53 54
 ActiveRecord::Base.class_eval do
54 55
   include ActiveRecord::Validations
@@ -63,6 +64,7 @@
63 64
   include ActiveRecord::Acts::Tree
64 65
   include ActiveRecord::Acts::List
65 66
   include ActiveRecord::Acts::NestedSet
  67
+  include ActiveRecord::Calculations
66 68
 end
67 69
 
68 70
 unless defined?(RAILS_CONNECTION_ADAPTERS)
55  activerecord/lib/active_record/base.rb
@@ -495,61 +495,6 @@ def delete_all(conditions = nil)
495 495
         connection.delete(sql, "#{name} Delete all")
496 496
       end
497 497
 
498  
-      # Count operates using three different approaches. 
499  
-      #
500  
-      # * Count all: By not passing any parameters to count, it will return a count of all the rows for the model.
501  
-      # * Count by conditions or joins: For backwards compatibility, you can pass in +conditions+ and +joins+ as individual parameters.
502  
-      # * Count using options will find the row count matched by the options used.
503  
-      #
504  
-      # The last approach, count using options, accepts an option hash as the only parameter. The options are:
505  
-      #
506  
-      # * <tt>:conditions</tt>: An SQL fragment like "administrator = 1" or [ "user_name = ?", username ]. See conditions in the intro.
507  
-      # * <tt>:joins</tt>: An SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id". (Rarely needed).
508  
-      #   The records will be returned read-only since they will have attributes that do not correspond to the table's columns.
509  
-      # * <tt>:include</tt>: Named associations that should be loaded alongside using LEFT OUTER JOINs. The symbols named refer
510  
-      #   to already defined associations. When using named associations count returns the number DISTINCT items for the model you're counting.
511  
-      #   See eager loading under Associations.
512  
-      #
513  
-      # Examples for counting all:
514  
-      #   Person.count         # returns the total count of all people
515  
-      #
516  
-      # Examples for count by +conditions+ and +joins+ (for backwards compatibility):
517  
-      #   Person.count("age > 26")  # returns the number of people older than 26
518  
-      #   Person.find("age > 26 AND job.salary > 60000", "LEFT JOIN jobs on jobs.person_id = person.id") # returns the total number of rows matching the conditions and joins fetched by SELECT COUNT(*).
519  
-      #
520  
-      # Examples for count with options:
521  
-      #   Person.count(:conditions => "age > 26")
522  
-      #   Person.count(:conditions => "age > 26 AND job.salary > 60000", :include => :job) # because of the named association, it finds the DISTINCT count using LEFT OUTER JOIN.
523  
-      #   Person.count(:conditions => "age > 26 AND job.salary > 60000", :joins => "LEFT JOIN jobs on jobs.person_id = person.id") # finds the number of rows matching the conditions and joins. 
524  
-      def count(*args) 
525  
-        options = {}
526  
-        
527  
-        #For backwards compatibility, we need to handle both count(conditions=nil, joins=nil) or count(options={}).
528  
-        if args.size >= 0 and args.size <= 2
529  
-          if args.first.is_a?(Hash)
530  
-            options = args.first
531  
-            #should we verify the options hash???
532  
-          else
533  
-            #Handle legacy paramter options: def count(conditions=nil, joins=nil) 
534  
-            options.merge!(:conditions => args[0]) if args.length > 0
535  
-            options.merge!(:joins => args[1]) if args.length > 1
536  
-          end
537  
-        else
538  
-          raise(ArgumentError, "Unexpected parameters passed to count(*args): expected either count(conditions=nil, joins=nil) or count(options={})")
539  
-        end
540  
-        
541  
-        options[:include] ? count_with_associations(options) : count_by_sql(construct_counter_sql(options))
542  
-      end
543  
-      
544  
-      def construct_counter_sql(options)
545  
-        sql  = "SELECT COUNT(" 
546  
-        sql << "DISTINCT " if options[:distinct]
547  
-        sql << "#{options[:select] || "#{table_name}.#{primary_key}"}) FROM #{table_name} "
548  
-        sql << " #{options[:joins]} " if options[:joins]
549  
-        add_conditions!(sql, options[:conditions])
550  
-        sql
551  
-      end
552  
-
553 498
       # Returns the result of an SQL statement that should only include a COUNT(*) in the SELECT part.
554 499
       #   Product.count_by_sql "SELECT COUNT(*) FROM sales s, customers c WHERE s.customer_id = c.id"
555 500
       def count_by_sql(sql)
197  activerecord/lib/active_record/calculations.rb
... ...
@@ -0,0 +1,197 @@
  1
+module ActiveRecord
  2
+  module Calculations
  3
+    def self.included(base)
  4
+      base.extend(ClassMethods)
  5
+    end
  6
+
  7
+    module ClassMethods
  8
+      # Count operates using three different approaches. 
  9
+      #
  10
+      # * Count all: By not passing any parameters to count, it will return a count of all the rows for the model.
  11
+      # * Count by conditions or joins: For backwards compatibility, you can pass in +conditions+ and +joins+ as individual parameters.
  12
+      # * Count using options will find the row count matched by the options used.
  13
+      #
  14
+      # The last approach, count using options, accepts an option hash as the only parameter. The options are:
  15
+      #
  16
+      # * <tt>:conditions</tt>: An SQL fragment like "administrator = 1" or [ "user_name = ?", username ]. See conditions in the intro.
  17
+      # * <tt>:joins</tt>: An SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id". (Rarely needed).
  18
+      #   The records will be returned read-only since they will have attributes that do not correspond to the table's columns.
  19
+      # * <tt>:include</tt>: Named associations that should be loaded alongside using LEFT OUTER JOINs. The symbols named refer
  20
+      #   to already defined associations. When using named associations count returns the number DISTINCT items for the model you're counting.
  21
+      #   See eager loading under Associations.
  22
+      #
  23
+      # Examples for counting all:
  24
+      #   Person.count         # returns the total count of all people
  25
+      #
  26
+      # Examples for count by +conditions+ and +joins+ (for backwards compatibility):
  27
+      #   Person.count("age > 26")  # returns the number of people older than 26
  28
+      #   Person.find("age > 26 AND job.salary > 60000", "LEFT JOIN jobs on jobs.person_id = person.id") # returns the total number of rows matching the conditions and joins fetched by SELECT COUNT(*).
  29
+      #
  30
+      # Examples for count with options:
  31
+      #   Person.count(:conditions => "age > 26")
  32
+      #   Person.count(:conditions => "age > 26 AND job.salary > 60000", :include => :job) # because of the named association, it finds the DISTINCT count using LEFT OUTER JOIN.
  33
+      #   Person.count(:conditions => "age > 26 AND job.salary > 60000", :joins => "LEFT JOIN jobs on jobs.person_id = person.id") # finds the number of rows matching the conditions and joins. 
  34
+      #   Person.count('id', :conditions => "age > 26") # Performs a COUNT(id)
  35
+      #   Person.count(:all, :conditions => "age > 26") # Performs a COUNT(*) (:all is an alias for '*')
  36
+      #
  37
+      # Note: Person.count(:all) will not work because it will use :all as the condition.  Use Person.count instead.
  38
+      def count(*args)
  39
+        options    = {}
  40
+        column_name = :all
  41
+        
  42
+        #For backwards compatibility, we need to handle both count(conditions=nil, joins=nil) or count(options={}).
  43
+        if args.size >= 0 and args.size <= 2
  44
+          if args.first.is_a?(Hash)
  45
+            options = args.first
  46
+            #should we verify the options hash???
  47
+          elsif args[1].is_a?(Hash)
  48
+            column_name = args.first
  49
+            options    = args[1]
  50
+          else
  51
+            # Handle legacy paramter options: def count(conditions=nil, joins=nil) 
  52
+            options.merge!(:conditions => args[0]) if args.length > 0
  53
+            options.merge!(:joins => args[1]) if args.length > 1
  54
+          end
  55
+        else
  56
+          raise(ArgumentError, "Unexpected parameters passed to count(*args): expected either count(conditions=nil, joins=nil) or count(options={})")
  57
+        end
  58
+        
  59
+        column_name = options[:select] if options[:select]
  60
+        options[:include] ? count_with_associations(options) : calculate(:count, column_name, options)
  61
+      end
  62
+
  63
+      # Calculates average value on a given column.  The value is returned as a float.  See #calculate for examples with options.
  64
+      #
  65
+      #   Person.average('age')
  66
+      def average(column_name, options = {})
  67
+        calculate(:avg, column_name, options)
  68
+      end
  69
+
  70
+      # Calculates the minimum value on a given column.  The value is returned with the same data type of the column..  See #calculate for examples with options.
  71
+      #
  72
+      #   Person.minimum('age')
  73
+      def minimum(column_name, options = {})
  74
+        calculate(:min, column_name, options)
  75
+      end
  76
+
  77
+      # Calculates the maximum value on a given column.  The value is returned with the same data type of the column..  See #calculate for examples with options.
  78
+      #
  79
+      #   Person.maximum('age')
  80
+      def maximum(column_name, options = {})
  81
+        calculate(:max, column_name, options)
  82
+      end
  83
+
  84
+      # Calculates the sum value on a given column.  The value is returned with the same data type of the column..  See #calculate for examples with options.
  85
+      #
  86
+      #   Person.maximum('age')
  87
+      def sum(column_name, options = {})
  88
+        calculate(:sum, column_name, options)
  89
+      end
  90
+
  91
+      # This calculates aggregate values in the given column:  Methods for count, sum, average, minimum, and maximum have been added as shortcuts.
  92
+      # Options such as :conditions, :order, :group, :having, and :joins can be passed to customize the query.  
  93
+      #
  94
+      # There are two basic forms of output:
  95
+      #   * Single aggregate value: The single value is type cast to Fixnum for COUNT, Float for AVG, and the given column's type for everything else.
  96
+      #   * Grouped values: This returns an ordered hash of the values and groups them by the :group option.  It takes either a column name, or the name 
  97
+      #     of a belongs_to association.
  98
+      #
  99
+      #       values = Person.maximum(:age, :group => 'last_name')
  100
+      #       puts values["Drake"]
  101
+      #       => 43
  102
+      #
  103
+      #       drake  = Family.find_by_last_name('Drake')
  104
+      #       values = Person.maximum(:age, :group => :family) # Person belongs_to :family
  105
+      #       puts values[drake]
  106
+      #       => 43
  107
+      #
  108
+      #       values.each do |family, max_age|
  109
+      #       ...
  110
+      #       end
  111
+      #
  112
+      # Examples:
  113
+      #   Person.calculate(:count, :all) # The same as Person.count
  114
+      #   Person.average(:age) # SELECT AVG(age) FROM people...
  115
+      #   Person.minimum(:age, :conditions => ['last_name != ?', 'Drake']) # Selects the minimum age for everyone with a last name other than 'Drake'
  116
+      #   Person.minimum(:age, :having => 'min(age) > 17', :group => :last_name) # Selects the minimum age for any family without any minors
  117
+      def calculate(operation, column_name, options = {})
  118
+        column_name = '*' if column_name == :all
  119
+        column     = columns.detect { |c| c.name.to_s == column_name.to_s }
  120
+        if options[:group]
  121
+          execute_grouped_calculation(operation, column_name, column, options)
  122
+        else
  123
+          execute_simple_calculation(operation, column_name, column, options)
  124
+        end
  125
+      end
  126
+
  127
+      protected
  128
+      def construct_calculation_sql(operation, column_name, options)
  129
+        sql  = ["SELECT #{operation}(#{'DISTINCT ' if options[:distinct]}#{column_name})"]
  130
+        sql << ", #{options[:group_field]}" if options[:group]
  131
+        sql << " FROM #{table_name} "
  132
+        add_joins!(sql, options)
  133
+        add_conditions!(sql, options[:conditions])
  134
+        sql << " GROUP BY #{options[:group_field]}" if options[:group]
  135
+        sql << " HAVING #{options[:having]}" if options[:group] && options[:having]
  136
+        sql.join
  137
+      end
  138
+
  139
+      def execute_simple_calculation(operation, column_name, column, options)
  140
+        value  = connection.select_value(construct_calculation_sql(operation, column_name, options))
  141
+        type_cast_calculated_value(value, column, operation)
  142
+      end
  143
+
  144
+      def execute_grouped_calculation(operation, column_name, column, options)
  145
+        group_attr      = options[:group].to_s
  146
+        association     = reflect_on_association(group_attr.to_sym)
  147
+        associated      = association && association.macro == :belongs_to # only count belongs_to associations
  148
+        group_field     = (associated ? "#{options[:group]}_id" : options[:group]).to_s
  149
+        sql             = construct_calculation_sql(operation, column_name, options.merge(:group_field => group_field))
  150
+        calculated_data = connection.select_all(sql)
  151
+
  152
+        if association
  153
+          key_ids     = calculated_data.collect { |row| row[group_field] }
  154
+          key_records = ActiveRecord::Base.send(:class_of_active_record_descendant, association.klass).find(key_ids)
  155
+          key_records = key_records.inject({}) { |hsh, r| hsh.merge(r.id => r) }
  156
+        end
  157
+
  158
+        calculated_data.inject(OrderedHash.new) do |all, row|
  159
+          key   = associated ? key_records[row[group_field].to_i] : row[column_key(group_field)]
  160
+          value = row[column_key("#{operation}(#{column_name})")]
  161
+          all << [key, type_cast_calculated_value(value, column, operation)]
  162
+        end
  163
+      end
  164
+
  165
+      private
  166
+      # converts a given key to the value that the database adapter returns as
  167
+      #
  168
+      #   users.id #=> id
  169
+      #   sum(id) #=> sum(id)
  170
+      #
  171
+      # psql strips off the () function too
  172
+      # 
  173
+      #   sum(id) #=> sum
  174
+      #
  175
+      # Should this go in a DB Adapter?
  176
+      def column_key(key)
  177
+        return key.split('.').last unless key =~ /\(/ # split off table alias
  178
+        case connection
  179
+          when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
  180
+            key.split('(').first.split('.').last
  181
+          else
  182
+            sql_func, sql_args = key.split('(')
  183
+            "#{sql_func.split('.').last}(#{sql_args}"
  184
+        end
  185
+      end
  186
+
  187
+      def type_cast_calculated_value(value, column, operation)
  188
+        operation = operation.to_s.downcase
  189
+        case operation
  190
+          when 'count' then value.to_i
  191
+          when 'avg'   then value.to_f
  192
+          else column ? column.type_cast(value) : value
  193
+        end
  194
+      end
  195
+    end
  196
+  end
  197
+end
136  activerecord/test/calculations_test.rb
... ...
@@ -0,0 +1,136 @@
  1
+require 'abstract_unit'
  2
+require 'fixtures/company'
  3
+require 'fixtures/topic'
  4
+
  5
+Company.has_many :accounts
  6
+
  7
+class CalculationsTest < Test::Unit::TestCase
  8
+  fixtures :companies, :accounts, :topics
  9
+
  10
+  def test_should_sum_field
  11
+    assert_equal 265, Account.sum(:credit_limit)
  12
+  end
  13
+
  14
+  def test_should_average_field
  15
+    value = Account.average(:credit_limit)
  16
+    assert_equal 53, value
  17
+    assert_kind_of Float, value
  18
+  end
  19
+
  20
+  def test_should_get_maximum_of_field
  21
+    assert_equal 60, Account.maximum(:credit_limit)
  22
+  end
  23
+
  24
+  def test_should_get_minimum_of_field
  25
+    assert_equal 50, Account.minimum(:credit_limit)
  26
+  end
  27
+
  28
+  def test_should_group_by_field
  29
+    c = Account.sum(:credit_limit, :group => :firm_id)
  30
+    %w( 1 6 2 ).each { |firm_id| assert c.keys.include?(firm_id) }
  31
+  end
  32
+
  33
+  def test_should_group_by_summed_field
  34
+    c = Account.sum(:credit_limit, :group => :firm_id)
  35
+    assert_equal 50,   c['1']
  36
+    assert_equal 105,  c['6']
  37
+    assert_equal 60,   c['2']
  38
+  end
  39
+
  40
+  def test_should_group_by_summed_field_having_condition
  41
+    c = Account.sum(:credit_limit, :group => :firm_id, 
  42
+                                   :having => 'sum(credit_limit) > 50')
  43
+    assert_nil        c['1']
  44
+    assert_equal 105, c['6']
  45
+    assert_equal 60,  c['2']
  46
+  end
  47
+
  48
+  def test_should_group_by_summed_association
  49
+    c = Account.sum(:credit_limit, :group => :firm)
  50
+    assert_equal 50,   c[companies(:first_firm)]
  51
+    assert_equal 105,  c[companies(:rails_core)]
  52
+    assert_equal 60,   c[companies(:first_client)]
  53
+  end
  54
+  
  55
+  def test_should_sum_field_with_conditions
  56
+    assert_equal 105, Account.sum(:credit_limit, :conditions => 'firm_id = 6')
  57
+  end
  58
+
  59
+  def test_should_group_by_summed_field_with_conditions
  60
+    c = Account.sum(:credit_limit, :conditions => 'firm_id > 1', 
  61
+                                   :group => :firm_id)
  62
+    assert_nil        c['1']
  63
+    assert_equal 105, c['6']
  64
+    assert_equal 60,  c['2']
  65
+  end
  66
+  
  67
+  def test_should_group_by_summed_field_with_conditions_and_having
  68
+    c = Account.sum(:credit_limit, :conditions => 'firm_id > 1', 
  69
+                                   :group => :firm_id, 
  70
+                                   :having => 'sum(credit_limit) > 60')
  71
+    assert_nil        c['1']
  72
+    assert_equal 105, c['6']
  73
+    assert_nil        c['2']
  74
+  end
  75
+
  76
+  def test_should_group_by_fields_with_table_alias
  77
+    c = Account.sum(:credit_limit, :group => 'accounts.firm_id')
  78
+    assert_equal 50,  c['1']
  79
+    assert_equal 105, c['6']
  80
+    assert_equal 60,  c['2']
  81
+  end
  82
+  
  83
+  def test_should_calculate_with_invalid_field
  84
+    assert_equal 5, Account.calculate(:count, '*')
  85
+    assert_equal 5, Account.calculate(:count, :all)
  86
+  end
  87
+  
  88
+  def test_should_calculate_grouped_with_invalid_field
  89
+    c = Account.count(:all, :group => 'accounts.firm_id')
  90
+    assert_equal 1, c['1']
  91
+    assert_equal 2, c['6']
  92
+    assert_equal 1, c['2']
  93
+  end
  94
+  
  95
+  def test_should_calculate_grouped_association_with_invalid_field
  96
+    c = Account.count(:all, :group => :firm)
  97
+    assert_equal 1, c[companies(:first_firm)]
  98
+    assert_equal 2, c[companies(:rails_core)]
  99
+    assert_equal 1, c[companies(:first_client)]
  100
+  end
  101
+
  102
+  def test_should_calculate_grouped_by_function
  103
+    c = Company.count(:all, :group => 'UPPER(type)')
  104
+    assert_equal 2, c[nil]
  105
+    assert_equal 1, c['DEPENDENTFIRM']
  106
+    assert_equal 3, c['CLIENT']
  107
+    assert_equal 2, c['FIRM']
  108
+  end
  109
+  
  110
+  def test_should_calculate_grouped_by_function_with_table_alias
  111
+    c = Topic.count(:all, :group => 'DATE(topics.written_on)')
  112
+    assert_equal 1, c["2003-07-15"]
  113
+    assert_equal 1, c["2003-07-16"]
  114
+  end
  115
+
  116
+  def test_should_sum_scoped_field
  117
+    assert_equal 15, companies(:rails_core).companies.sum(:id)
  118
+  end
  119
+
  120
+  def test_should_sum_scoped_field_with_conditions
  121
+    assert_equal 8,  companies(:rails_core).companies.sum(:id, :conditions => 'id > 7')
  122
+  end
  123
+
  124
+  def test_should_group_by_scoped_field
  125
+    c = companies(:rails_core).companies.sum(:id, :group => :name)
  126
+    assert_equal 7, c['Leetsoft']
  127
+    assert_equal 8, c['Jadedpixel']
  128
+  end
  129
+
  130
+  def test_should_group_by_summed_field_with_conditions_and_having
  131
+    c = companies(:rails_core).companies.sum(:id, :group => :name,
  132
+                                                  :having => 'sum(id) > 7')
  133
+    assert_nil      c['Leetsoft']
  134
+    assert_equal 8, c['Jadedpixel']
  135
+  end
  136
+end
5  activerecord/test/fixtures/accounts.yml
@@ -16,3 +16,8 @@ last_account:
16 16
   id: 4
17 17
   firm_id: 2
18 18
   credit_limit: 60
  19
+
  20
+rails_core_account_2:
  21
+  id: 5
  22
+  firm_id: 6
  23
+  credit_limit: 55
34  activesupport/lib/active_support/ordered_options.rb
... ...
@@ -1,7 +1,5 @@
1  
-class OrderedOptions < Array #:nodoc:
2  
-  def []=(key, value)
3  
-    key = key.to_sym
4  
-    
  1
+class OrderedHash < Array #:nodoc:
  2
+  def []=(key, value)    
5 3
     if pair = find_pair(key)
6 4
       pair.pop
7 5
       pair << value
@@ -11,16 +9,12 @@ def []=(key, value)
11 9
   end
12 10
   
13 11
   def [](key)
14  
-    pair = find_pair(key.to_sym)
  12
+    pair = find_pair(key)
15 13
     pair ? pair.last : nil
16 14
   end
17 15
 
18  
-  def method_missing(name, *args)
19  
-    if name.to_s =~ /(.*)=$/
20  
-      self[$1.to_sym] = args.first
21  
-    else
22  
-      self[name]
23  
-    end
  16
+  def keys
  17
+    self.collect { |i| i.first }
24 18
   end
25 19
 
26 20
   private
@@ -28,4 +22,22 @@ def find_pair(key)
28 22
       self.each { |i| return i if i.first == key }
29 23
       return false
30 24
     end
  25
+end
  26
+
  27
+class OrderedOptions < OrderedHash #:nodoc:
  28
+  def []=(key, value)
  29
+    super(key.to_sym, value)
  30
+  end
  31
+  
  32
+  def [](key)
  33
+    super(key.to_sym)
  34
+  end
  35
+
  36
+  def method_missing(name, *args)
  37
+    if name.to_s =~ /(.*)=$/
  38
+      self[$1.to_sym] = args.first
  39
+    else
  40
+      self[name]
  41
+    end
  42
+  end
31 43
 end

0 notes on commit 99307b9

Please sign in to comment.
Something went wrong with that request. Please try again.