Commit 46e7a6976a651c537b288aa240fad26ecdf12867

Authored by Andrew Kane
1 parent 81c1bce0

Moved logic into Groupdate::Magic class

lib/groupdate.rb
1 1 require "active_support/time"
2 2 require "groupdate/version"
  3 +require "groupdate/magic"
3 4 require "groupdate/enumerable"
4 5 require "groupdate/active_record"
5 6  
... ...
lib/groupdate/enumerable.rb
1 1 module Enumerable
2 2  
3   - time_fields = %w(second minute hour day week month year)
4   - number_fields = %w(day_of_week hour_of_day)
5   - (time_fields + number_fields).each do |field|
  3 + %i(second minute hour day week month year day_of_week hour_of_day).each do |field|
6 4 define_method :"group_by_#{field}" do |options = {}, &block|
7   - time_zone = options[:time_zone] || Groupdate.time_zone || Time.zone || "Etc/UTC"
8   - if time_zone.is_a?(ActiveSupport::TimeZone) or time_zone = ActiveSupport::TimeZone[time_zone]
9   - time_zone_object = time_zone
10   - time_zone = time_zone.tzinfo.name
11   - else
12   - raise "Unrecognized time zone"
13   - end
14   -
15   - # for week
16   - week_start = [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
17   - if field == "week" and !week_start
18   - raise "Unrecognized :week_start option"
19   - end
20   -
21   - # for day
22   - day_start = (options[:day_start] || Groupdate.day_start).to_i
23   -
24   - range = options.has_key?(:range) ? options[:range] : true
25   -
26   - Groupdate::Series.new(self, field, nil, time_zone_object, range, week_start, day_start, 0, options.slice(:reverse)).perform(:group_by, &block)
  5 + Groupdate::Magic.new(field, options).group_by(self, &block)
27 6 end
28 7 end
29 8  
... ...
lib/groupdate/magic.rb 0 → 100644
... ... @@ -0,0 +1,263 @@
  1 +module Groupdate
  2 + class Magic
  3 + attr_accessor :field, :options
  4 +
  5 + def initialize(field, options)
  6 + @field = field
  7 + @options = options
  8 +
  9 + if !time_zone
  10 + raise "Unrecognized time zone"
  11 + end
  12 +
  13 + if field == :week and !week_start
  14 + raise "Unrecognized :week_start option"
  15 + end
  16 + end
  17 +
  18 + def time_zone
  19 + @time_zone ||= begin
  20 + time_zone = options[:time_zone] || Groupdate.time_zone || Time.zone || "Etc/UTC"
  21 + time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone]
  22 + end
  23 + end
  24 +
  25 + def week_start
  26 + @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
  27 + end
  28 +
  29 + def day_start
  30 + @day_start ||= (options[:day_start] || Groupdate.day_start).to_i
  31 + end
  32 +
  33 + def group_by(enum, &block)
  34 + series(enum.group_by{|v| round_time(block.call(v)) }, [])
  35 + end
  36 +
  37 + def time_range
  38 + @time_range ||= begin
  39 + time_range = options[:range]
  40 + if !time_range and options[:last]
  41 + step = 1.send(field) if 1.respond_to?(field)
  42 + if step
  43 + now = Time.now
  44 + time_range = round_time(now - (options[:last].to_i - 1).send(field))..now
  45 + end
  46 + end
  47 + time_range
  48 + end
  49 + end
  50 +
  51 + def relation(column, relation)
  52 + column = relation.connection.quote_table_name(column)
  53 + time_zone = self.time_zone.tzinfo.name
  54 +
  55 + adapter_name = relation.connection.adapter_name
  56 + query =
  57 + case adapter_name
  58 + when "MySQL", "Mysql2"
  59 + case field
  60 + when :day_of_week # Sunday = 0, Monday = 1, etc
  61 + # use CONCAT for consistent return type (String)
  62 + ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?)) - 1", time_zone]
  63 + when :hour_of_day
  64 + ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start}) % 24", time_zone]
  65 + when :week
  66 + ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} HOUR)) % 7) DAY) - INTERVAL #{day_start} HOUR, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} HOUR, ?, '+00:00')", time_zone, time_zone, time_zone]
  67 + else
  68 + format =
  69 + case field
  70 + when :second
  71 + "%Y-%m-%d %H:%i:%S"
  72 + when :minute
  73 + "%Y-%m-%d %H:%i:00"
  74 + when :hour
  75 + "%Y-%m-%d %H:00:00"
  76 + when :day
  77 + "%Y-%m-%d 00:00:00"
  78 + when :month
  79 + "%Y-%m-01 00:00:00"
  80 + else # year
  81 + "%Y-01-01 00:00:00"
  82 + end
  83 +
  84 + ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} HOUR)", time_zone, time_zone]
  85 + end
  86 + when "PostgreSQL", "PostGIS"
  87 + case field
  88 + when :day_of_week
  89 + ["EXTRACT(DOW from (#{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour'))::integer", time_zone]
  90 + when :hour_of_day
  91 + ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour')::integer", time_zone]
  92 + when :week # start on Sunday, not PostgreSQL default Monday
  93 + ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start}' hour) AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start}' hour) AT TIME ZONE ?", time_zone, time_zone]
  94 + else
  95 + ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} hour') AT TIME ZONE ?) + INTERVAL '#{day_start} hour') AT TIME ZONE ?", time_zone, time_zone]
  96 + end
  97 + else
  98 + raise "Connection adapter not supported: #{adapter_name}"
  99 + end
  100 +
  101 + group = relation.group(Groupdate::OrderHack.new(relation.send(:sanitize_sql_array, query), field, time_zone))
  102 + if options[:series] == false
  103 + group
  104 + else
  105 + relation =
  106 + if time_range.is_a?(Range)
  107 + # doesn't matter whether we include the end of a ... range - it will be excluded later
  108 + group.where("#{column} >= ? AND #{column} <= ?", time_range.first, time_range.last)
  109 + else
  110 + group.where("#{column} IS NOT NULL")
  111 + end
  112 +
  113 + # TODO do not change object state
  114 + @group_index = group.group_values.size - 1
  115 +
  116 + Groupdate::Series.new(self, relation)
  117 + end
  118 + end
  119 +
  120 + def series(count, default_value, multiple_groups = false, reverse = false)
  121 + series =
  122 + case field
  123 + when :day_of_week
  124 + 0..6
  125 + when :hour_of_day
  126 + 0..23
  127 + else
  128 + time_range = self.time_range
  129 + time_range =
  130 + if time_range.is_a?(Range)
  131 + time_range
  132 + else
  133 + # use first and last values
  134 + sorted_keys =
  135 + if multiple_groups
  136 + count.keys.map{|k| k[@group_index] }.sort
  137 + else
  138 + count.keys.sort
  139 + end
  140 + sorted_keys.first..sorted_keys.last
  141 + end
  142 +
  143 + if time_range.first
  144 + series = [round_time(time_range.first)]
  145 +
  146 + step = 1.send(field)
  147 +
  148 + while time_range.cover?(series.last + step)
  149 + series << series.last + step
  150 + end
  151 +
  152 + if multiple_groups
  153 + keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
  154 + series = series.reverse if reverse
  155 + keys.flat_map do |k|
  156 + series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
  157 + end
  158 + else
  159 + series
  160 + end
  161 + else
  162 + []
  163 + end
  164 + end
  165 +
  166 + # reversed above if multiple groups
  167 + if !multiple_groups and reverse
  168 + series = series.to_a.reverse
  169 + end
  170 +
  171 + key_format =
  172 + if options[:format]
  173 + if options[:format].respond_to?(:call)
  174 + options[:format]
  175 + else
  176 + sunday = time_zone.parse("2014-03-02 00:00:00")
  177 + lambda do |key|
  178 + case field
  179 + when :hour_of_day
  180 + key = sunday + key.hours + day_start.hours
  181 + when :day_of_week
  182 + key = sunday + key.days
  183 + end
  184 + key.strftime(options[:format].to_s)
  185 + end
  186 + end
  187 + else
  188 + lambda{|k| k }
  189 + end
  190 +
  191 + Hash[series.map do |k|
  192 + [multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), count[k] || default_value]
  193 + end]
  194 + end
  195 +
  196 + def perform(relation, method, *args, &block)
  197 + # undo reverse since we do not want this to appear in the query
  198 + reverse = relation.reverse_order_value
  199 + if reverse
  200 + relation = relation.reverse_order
  201 + end
  202 + order = relation.order_values.first
  203 + if order.is_a?(String)
  204 + parts = order.split(" ")
  205 + reverse_order = (parts.size == 2 && parts[0].to_sym == field && parts[1].to_s.downcase == "desc")
  206 + reverse = !reverse if reverse_order
  207 + end
  208 +
  209 + multiple_groups = relation.group_values.size > 1
  210 +
  211 + cast_method =
  212 + case field
  213 + when :day_of_week, :hour_of_day
  214 + lambda{|k| k.to_i }
  215 + else
  216 + utc = ActiveSupport::TimeZone["UTC"]
  217 + lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(time_zone) }
  218 + end
  219 +
  220 + count =
  221 + begin
  222 + Hash[ relation.send(method, *args, &block).map{|k, v| [multiple_groups ? k[0...@group_index] + [cast_method.call(k[@group_index])] + k[(@group_index + 1)..-1] : cast_method.call(k), v] } ]
  223 + rescue NoMethodError
  224 + raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
  225 + end
  226 +
  227 + series(count, 0, multiple_groups, reverse)
  228 + end
  229 +
  230 + def round_time(time)
  231 + time = time.to_time.in_time_zone(time_zone) - day_start.hours
  232 +
  233 + time =
  234 + case field
  235 + when :second
  236 + time.change(:usec => 0)
  237 + when :minute
  238 + time.change(:sec => 0)
  239 + when :hour
  240 + time.change(:min => 0)
  241 + when :day
  242 + time.beginning_of_day
  243 + when :week
  244 + # same logic as MySQL group
  245 + weekday = (time.wday - 1) % 7
  246 + (time - ((7 - week_start + weekday) % 7).days).midnight
  247 + when :month
  248 + time.beginning_of_month
  249 + when :year
  250 + time.beginning_of_year
  251 + when :hour_of_day
  252 + time.hour
  253 + when :day_of_week
  254 + (7 - week_start + ((time.wday - 1) % 7) % 7)
  255 + else
  256 + raise "Invalid field"
  257 + end
  258 +
  259 + time.is_a?(Time) ? time + day_start.hours : time
  260 + end
  261 +
  262 + end
  263 +end
... ...
lib/groupdate/order_hack.rb
... ... @@ -4,7 +4,7 @@ module Groupdate
4 4  
5 5 def initialize(str, field, time_zone)
6 6 super(str)
7   - @field = field
  7 + @field = field.to_s
8 8 @time_zone = time_zone
9 9 end
10 10 end
... ...
lib/groupdate/scopes.rb
... ... @@ -4,83 +4,17 @@ require &quot;active_record&quot;
4 4  
5 5 module Groupdate
6 6 module Scopes
7   - time_fields = %w(second minute hour day week month year)
8   - number_fields = %w(day_of_week hour_of_day)
9   - (time_fields + number_fields).each do |field|
  7 +
  8 + %i(second minute hour day week month year day_of_week hour_of_day).each do |field|
10 9 define_method :"group_by_#{field}" do |*args|
11 10 args = args.dup
12 11 options = args[-1].is_a?(Hash) ? args.pop : {}
13   - column = connection.quote_table_name(args[0])
14   - time_zone = args[1] || options[:time_zone] || Groupdate.time_zone || Time.zone || "Etc/UTC"
15   - if time_zone.is_a?(ActiveSupport::TimeZone) or time_zone = ActiveSupport::TimeZone[time_zone]
16   - time_zone_object = time_zone
17   - time_zone = time_zone.tzinfo.name
18   - else
19   - raise "Unrecognized time zone"
20   - end
21   -
22   - # for week
23   - week_start = [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
24   - if field == "week" and !week_start
25   - raise "Unrecognized :week_start option"
26   - end
27   -
28   - # for day
29   - day_start = (options[:day_start] || Groupdate.day_start).to_i
  12 + options[:time_zone] ||= args[1] unless args[1].nil?
  13 + options[:range] ||= args[2] unless args[2].nil?
30 14  
31   - query =
32   - case connection.adapter_name
33   - when "MySQL", "Mysql2"
34   - case field
35   - when "day_of_week" # Sunday = 0, Monday = 1, etc
36   - # use CONCAT for consistent return type (String)
37   - ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?)) - 1", time_zone]
38   - when "hour_of_day"
39   - ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start}) % 24", time_zone]
40   - when "week"
41   - ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} HOUR)) % 7) DAY) - INTERVAL #{day_start} HOUR, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} HOUR, ?, '+00:00')", time_zone, time_zone, time_zone]
42   - else
43   - format =
44   - case field
45   - when "second"
46   - "%Y-%m-%d %H:%i:%S"
47   - when "minute"
48   - "%Y-%m-%d %H:%i:00"
49   - when "hour"
50   - "%Y-%m-%d %H:00:00"
51   - when "day"
52   - "%Y-%m-%d 00:00:00"
53   - when "month"
54   - "%Y-%m-01 00:00:00"
55   - else # year
56   - "%Y-01-01 00:00:00"
57   - end
58   -
59   - ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} HOUR)", time_zone, time_zone]
60   - end
61   - when "PostgreSQL", "PostGIS"
62   - case field
63   - when "day_of_week"
64   - ["EXTRACT(DOW from (#{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour'))::integer", time_zone]
65   - when "hour_of_day"
66   - ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour')::integer", time_zone]
67   - when "week" # start on Sunday, not PostgreSQL default Monday
68   - ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start}' hour) AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start}' hour) AT TIME ZONE ?", time_zone, time_zone]
69   - else
70   - ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} hour') AT TIME ZONE ?) + INTERVAL '#{day_start} hour') AT TIME ZONE ?", time_zone, time_zone]
71   - end
72   - else
73   - raise "Connection adapter not supported: #{connection.adapter_name}"
74   - end
75   -
76   - group = group(Groupdate::OrderHack.new(sanitize_sql_array(query), field, time_zone))
77   - range = args[2] || options[:range] || true
78   - unless options[:series] == false
79   - Series.new(group, field, column, time_zone_object, range, week_start, day_start, group.group_values.size - 1, options)
80   - else
81   - group
82   - end
  15 + Groupdate::Magic.new(field, options).relation(args[0], self)
83 16 end
84 17 end
  18 +
85 19 end
86 20 end
... ...
lib/groupdate/series.rb
1 1 module Groupdate
2 2 class Series
3   - attr_accessor :relation
  3 + attr_accessor :magic, :relation
4 4  
5   - def initialize(relation, field, column, time_zone, time_range, week_start, day_start, group_index, options)
  5 + def initialize(magic, relation)
  6 + @magic = magic
6 7 @relation = relation
7   - @field = field
8   - @column = column
9   - @time_zone = time_zone
10   - @time_range = time_range
11   - @week_start = week_start
12   - @day_start = day_start
13   - @group_index = group_index
14   - @options = options
15   - end
16   -
17   - def perform(method = nil, *args, &block)
18   - utc = ActiveSupport::TimeZone["UTC"]
19   -
20   - time_range = @time_range
21   - if !time_range.is_a?(Range) and @options[:last]
22   - step = 1.send(@field) if 1.respond_to?(@field)
23   - if step
24   - now = Time.now
25   - time_range = round_time(now - (@options[:last].to_i - 1).send(@field))..now
26   - end
27   - end
28   -
29   - if @relation.is_a?(Enumerable)
30   - multiple_groups = false
31   - reverse = @options[:reverse] || false
32   - count = @relation.group_by{|v| round_time(block.call(v)) }
33   - default_value = []
34   - else
35   - relation =
36   - if time_range.is_a?(Range)
37   - # doesn't matter whether we include the end of a ... range - it will be excluded later
38   - @relation.where("#{@column} >= ? AND #{@column} <= ?", time_range.first, time_range.last)
39   - else
40   - @relation.where("#{@column} IS NOT NULL")
41   - end
42   -
43   - # undo reverse since we do not want this to appear in the query
44   - reverse = relation.reverse_order_value
45   - if reverse
46   - relation = relation.reverse_order
47   - end
48   - order = relation.order_values.first
49   - if order.is_a?(String)
50   - parts = order.split(" ")
51   - reverse_order = (parts.size == 2 && parts[0] == @field && parts[1].to_s.downcase == "desc")
52   - reverse = !reverse if reverse_order
53   - end
54   -
55   - multiple_groups = relation.group_values.size > 1
56   -
57   - cast_method =
58   - case @field
59   - when "day_of_week", "hour_of_day"
60   - lambda{|k| k.to_i }
61   - else
62   - lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(@time_zone) }
63   - end
64   -
65   - count =
66   - begin
67   - Hash[ relation.send(method, *args, &block).map{|k, v| [multiple_groups ? k[0...@group_index] + [cast_method.call(k[@group_index])] + k[(@group_index + 1)..-1] : cast_method.call(k), v] } ]
68   - rescue NoMethodError
69   - raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
70   - end
71   -
72   - default_value = 0
73   - end
74   -
75   - series =
76   - case @field
77   - when "day_of_week"
78   - 0..6
79   - when "hour_of_day"
80   - 0..23
81   - else
82   - time_range =
83   - if time_range.is_a?(Range)
84   - time_range
85   - else
86   - # use first and last values
87   - sorted_keys =
88   - if multiple_groups
89   - count.keys.map{|k| k[@group_index] }.sort
90   - else
91   - count.keys.sort
92   - end
93   - sorted_keys.first..sorted_keys.last
94   - end
95   -
96   - if time_range.first
97   - series = [round_time(time_range.first)]
98   -
99   - step = 1.send(@field)
100   -
101   - while time_range.cover?(series.last + step)
102   - series << series.last + step
103   - end
104   -
105   - if multiple_groups
106   - keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
107   - series = series.reverse if reverse
108   - keys.flat_map do |k|
109   - series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
110   - end
111   - else
112   - series
113   - end
114   - else
115   - []
116   - end
117   - end
118   -
119   - # reversed above if multiple groups
120   - if !multiple_groups and reverse
121   - series = series.to_a.reverse
122   - end
123   -
124   - key_format =
125   - if @options[:format]
126   - if @options[:format].respond_to?(:call)
127   - @options[:format]
128   - else
129   - sunday = @time_zone.parse("2014-03-02 00:00:00")
130   - lambda do |key|
131   - case @field
132   - when "hour_of_day"
133   - key = sunday + key.hours + @day_start.hours
134   - when "day_of_week"
135   - key = sunday + key.days
136   - end
137   - key.strftime(@options[:format].to_s)
138   - end
139   - end
140   - else
141   - lambda{|k| k }
142   - end
143   -
144   - Hash[series.map do |k|
145   - [multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), count[k] || default_value]
146   - end]
147   - end
148   -
149   - def round_time(time)
150   - time = time.to_time.in_time_zone(@time_zone) - @day_start.hours
151   -
152   - time =
153   - case @field
154   - when "second"
155   - time.change(:usec => 0)
156   - when "minute"
157   - time.change(:sec => 0)
158   - when "hour"
159   - time.change(:min => 0)
160   - when "day"
161   - time.beginning_of_day
162   - when "week"
163   - # same logic as MySQL group
164   - weekday = (time.wday - 1) % 7
165   - (time - ((7 - @week_start + weekday) % 7).days).midnight
166   - when "month"
167   - time.beginning_of_month
168   - when "hour_of_day"
169   - time.hour
170   - when "day_of_week"
171   - (7 - @week_start + ((time.wday - 1) % 7) % 7)
172   - else # year
173   - time.beginning_of_year
174   - end
175   -
176   - time.is_a?(Time) ? time + @day_start.hours : time
177   - end
178   -
179   - def clone
180   - Groupdate::Series.new(@relation, @field, @column, @time_zone, @time_range, @week_start, @day_start, @group_index, @options)
181 8 end
182 9  
183 10 # clone to prevent modifying original variables
184 11 def method_missing(method, *args, &block)
185 12 # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation/calculations.rb
186 13 if ActiveRecord::Calculations.method_defined?(method)
187   - clone.perform(method, *args, &block)
  14 + magic.perform(relation, method, *args, &block)
188 15 elsif @relation.respond_to?(method)
189   - series = clone
190   - series.relation = @relation.send(method, *args, &block)
191   - series
  16 + Groupdate::Series.new(magic, relation.send(method, *args, &block))
192 17 else
193 18 super
194 19 end
195 20 end
196 21  
197 22 def respond_to?(method, include_all = false)
198   - ActiveRecord::Calculations.method_defined?(method) || @relation.respond_to?(method) || super
  23 + ActiveRecord::Calculations.method_defined?(method) || relation.respond_to?(method) || super
199 24 end
200 25  
201   - end # Series
  26 + end
202 27 end
... ...