groupdate 2.1.1 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 358ad13de46aa60a282d59df8b6de77acfced7b7
4
- data.tar.gz: c90febd166be21d77ce02aac0335b0691ffb8950
3
+ metadata.gz: cc3e5e97ecbb9ce920165a63a9cba725b6f4c0d5
4
+ data.tar.gz: 366b811759baa6dfc4918a91428f36a1f503e35e
5
5
  SHA512:
6
- metadata.gz: 69a1f081be83ddd6bf8d36ce2839bc47957b64ca35f2b71f61a545cf0f4c77fcc80e1a2fbc216aaa5f5a7429c45b1fb07139b99ca640f0efa068454e0965cece
7
- data.tar.gz: 29865ae92770fed0d36ed7e4d9d21042057a152e12ed74d49ee0214328a960d3b6008ce63c6fca497982920dbdd5f322ffc0986976ef419c477b7995f37cda8c
6
+ metadata.gz: 0245a5804aca2739f67d6b53552ba584533d237ed14ed47112a8a2995ddc3fa4e3808cc8bb304c58697886fd5a9431870f6f3a1c99e4cdfc57bf815a8b973e6b
7
+ data.tar.gz: e2c50ef5c06f46a7a311828a835b271ababe0e6589a6d1374097fa7830ab0416c875a5b49c8f539799d3e73181b6fe42e5a0a58556a14a666736abd953788db3
@@ -3,6 +3,11 @@ rvm:
3
3
  - 1.9.3
4
4
  - 2.0.0
5
5
  - jruby
6
+ gemfile:
7
+ - Gemfile
8
+ - gemfiles/activerecord31.gemfile
9
+ - gemfiles/activerecord32.gemfile
10
+ - gemfiles/activerecord40.gemfile
6
11
  script: bundle exec rake test
7
12
  before_script:
8
13
  - mysql -e 'create database groupdate_test;'
@@ -1,3 +1,7 @@
1
+ ## 2.2.0
2
+
3
+ - Added support for Arrays and Hashes
4
+
1
5
  ## 2.1.1
2
6
 
3
7
  - Fixed format option with multiple groups
data/Gemfile CHANGED
@@ -2,3 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in groupdate.gemspec
4
4
  gemspec
5
+
6
+ gem "activerecord", "~> 4.1"
data/README.md CHANGED
@@ -11,9 +11,9 @@ The simplest way to group by:
11
11
 
12
12
  :cake: Get the entire series - **the other best part**
13
13
 
14
- Works with Rails 3.0+
14
+ Works with Rails 3.1+
15
15
 
16
- Supports PostgreSQL and MySQL
16
+ Supports PostgreSQL and MySQL, plus Arrays and Hashes
17
17
 
18
18
  [![Build Status](https://travis-ci.org/ankane/groupdate.png)](https://travis-ci.org/ankane/groupdate)
19
19
 
@@ -137,6 +137,18 @@ User.group_by_hour_of_day(:created_at, format: "%l %P").count.keys.first # 12 am
137
137
 
138
138
  Takes a `String`, which is passed to [strftime](http://strfti.me/), or a `Proc`
139
139
 
140
+ ## Arrays and Hashes
141
+
142
+ ```ruby
143
+ users.group_by_day{|u| u.created_at } # or group_by_day(&:created_at)
144
+ ```
145
+
146
+ Supports the same options as above
147
+
148
+ ```ruby
149
+ users.group_by_day(time_zone: time_zone){|u| u.created_at }
150
+ ```
151
+
140
152
  ## Installation
141
153
 
142
154
  Add this line to your application’s Gemfile:
@@ -3,4 +3,4 @@ source 'https://rubygems.org'
3
3
  # Specify your gem's dependencies in searchkick.gemspec
4
4
  gemspec path: "../"
5
5
 
6
- gem "activerecord", "3.1.12"
6
+ gem "activerecord", "~> 3.1"
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in searchkick.gemspec
4
+ gemspec path: "../"
5
+
6
+ gem "activerecord", "~> 3.2"
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in searchkick.gemspec
4
+ gemspec path: "../"
5
+
6
+ gem "activerecord", "~> 4.0"
@@ -18,11 +18,12 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.add_dependency "activerecord", ">= 3.0.0"
21
+ spec.add_dependency "activesupport", ">= 3"
22
22
 
23
23
  spec.add_development_dependency "bundler", "~> 1.3"
24
24
  spec.add_development_dependency "rake"
25
- spec.add_development_dependency "minitest"
25
+ spec.add_development_dependency "minitest", ">= 5"
26
+ spec.add_development_dependency "activerecord"
26
27
 
27
28
  if RUBY_PLATFORM == "java"
28
29
  spec.add_development_dependency "activerecord-jdbcpostgresql-adapter"
@@ -1,29 +1,21 @@
1
+ require "active_support/core_ext/module/attribute_accessors"
2
+ require "active_support/time"
1
3
  require "groupdate/version"
2
- require "groupdate/scopes"
3
-
4
- ActiveRecord::Base.send :extend, Groupdate::Scopes
5
-
6
- # hack for **unfixed** rails issue
7
- # https://github.com/rails/rails/issues/7121
8
- module ActiveRecord
9
- module Calculations
10
-
11
- private
12
-
13
- def column_alias_for_with_hack(*keys)
14
- if keys.first.is_a?(Groupdate::OrderHack)
15
- keys.first.field
16
- else
17
- column_alias_for_without_hack(*keys)
18
- end
19
- end
20
- alias_method_chain :column_alias_for, :hack
21
-
22
- end
23
- end
4
+ require "groupdate/magic"
24
5
 
25
6
  module Groupdate
7
+ FIELDS = [:second, :minute, :hour, :day, :week, :month, :year, :day_of_week, :hour_of_day]
8
+ METHODS = FIELDS.map{|v| :"group_by_#{v}" }
9
+
26
10
  mattr_accessor :week_start, :day_start, :time_zone
27
11
  self.week_start = :sun
28
12
  self.day_start = 0
29
13
  end
14
+
15
+ require "groupdate/enumerable"
16
+ begin
17
+ require "active_record"
18
+ rescue LoadError
19
+ # do nothing
20
+ end
21
+ require "groupdate/active_record" if defined?(ActiveRecord)
@@ -0,0 +1,44 @@
1
+ require "active_record"
2
+ require "groupdate/order_hack"
3
+ require "groupdate/scopes"
4
+ require "groupdate/series"
5
+
6
+ ActiveRecord::Base.send(:extend, Groupdate::Scopes)
7
+
8
+ module ActiveRecord
9
+ class Relation
10
+
11
+ if ActiveRecord::VERSION::MAJOR == 3 and ActiveRecord::VERSION::MINOR < 2
12
+
13
+ def method_missing_with_hack(method, *args, &block)
14
+ if Groupdate::METHODS.include?(method)
15
+ scoping { @klass.send(method, *args, &block) }
16
+ else
17
+ method_missing_without_hack(method, *args, &block)
18
+ end
19
+ end
20
+ alias_method_chain :method_missing, :hack
21
+
22
+ end
23
+
24
+ end
25
+ end
26
+
27
+ # hack for **unfixed** rails issue
28
+ # https://github.com/rails/rails/issues/7121
29
+ module ActiveRecord
30
+ module Calculations
31
+
32
+ private
33
+
34
+ def column_alias_for_with_hack(*keys)
35
+ if keys.first.is_a?(Groupdate::OrderHack)
36
+ keys.first.field
37
+ else
38
+ column_alias_for_without_hack(*keys)
39
+ end
40
+ end
41
+ alias_method_chain :column_alias_for, :hack
42
+
43
+ end
44
+ end
@@ -0,0 +1,9 @@
1
+ module Enumerable
2
+
3
+ Groupdate::FIELDS.each do |field|
4
+ define_method :"group_by_#{field}" do |options = {}, &block|
5
+ Groupdate::Magic.new(field, options).group_by(self, &block)
6
+ end
7
+ end
8
+
9
+ end
@@ -0,0 +1,267 @@
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 group_by(enum, &block)
19
+ series(enum.group_by{|v| v = yield(v); v ? round_time(v) : nil }, [])
20
+ end
21
+
22
+ def relation(column, relation)
23
+ column = relation.connection.quote_table_name(column)
24
+ time_zone = self.time_zone.tzinfo.name
25
+
26
+ adapter_name = relation.connection.adapter_name
27
+ query =
28
+ case adapter_name
29
+ when "MySQL", "Mysql2"
30
+ case field
31
+ when :day_of_week # Sunday = 0, Monday = 1, etc
32
+ # use CONCAT for consistent return type (String)
33
+ ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?)) - 1", time_zone]
34
+ when :hour_of_day
35
+ ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start}) % 24", time_zone]
36
+ when :week
37
+ ["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]
38
+ else
39
+ format =
40
+ case field
41
+ when :second
42
+ "%Y-%m-%d %H:%i:%S"
43
+ when :minute
44
+ "%Y-%m-%d %H:%i:00"
45
+ when :hour
46
+ "%Y-%m-%d %H:00:00"
47
+ when :day
48
+ "%Y-%m-%d 00:00:00"
49
+ when :month
50
+ "%Y-%m-01 00:00:00"
51
+ else # year
52
+ "%Y-01-01 00:00:00"
53
+ end
54
+
55
+ ["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]
56
+ end
57
+ when "PostgreSQL", "PostGIS"
58
+ case field
59
+ when :day_of_week
60
+ ["EXTRACT(DOW from (#{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour'))::integer", time_zone]
61
+ when :hour_of_day
62
+ ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour')::integer", time_zone]
63
+ when :week # start on Sunday, not PostgreSQL default Monday
64
+ ["(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]
65
+ else
66
+ ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} hour') AT TIME ZONE ?) + INTERVAL '#{day_start} hour') AT TIME ZONE ?", time_zone, time_zone]
67
+ end
68
+ else
69
+ raise "Connection adapter not supported: #{adapter_name}"
70
+ end
71
+
72
+ group = relation.group(Groupdate::OrderHack.new(relation.send(:sanitize_sql_array, query), field, time_zone))
73
+ if options[:series] == false
74
+ group
75
+ else
76
+ relation =
77
+ if time_range.is_a?(Range)
78
+ # doesn't matter whether we include the end of a ... range - it will be excluded later
79
+ group.where("#{column} >= ? AND #{column} <= ?", time_range.first, time_range.last)
80
+ else
81
+ group.where("#{column} IS NOT NULL")
82
+ end
83
+
84
+ # TODO do not change object state
85
+ @group_index = group.group_values.size - 1
86
+
87
+ Groupdate::Series.new(self, relation)
88
+ end
89
+ end
90
+
91
+ def perform(relation, method, *args, &block)
92
+ # undo reverse since we do not want this to appear in the query
93
+ reverse = relation.reverse_order_value
94
+ if reverse
95
+ relation = relation.reverse_order
96
+ end
97
+ order = relation.order_values.first
98
+ if order.is_a?(String)
99
+ parts = order.split(" ")
100
+ reverse_order = (parts.size == 2 && parts[0].to_sym == field && parts[1].to_s.downcase == "desc")
101
+ reverse = !reverse if reverse_order
102
+ end
103
+
104
+ multiple_groups = relation.group_values.size > 1
105
+
106
+ cast_method =
107
+ case field
108
+ when :day_of_week, :hour_of_day
109
+ lambda{|k| k.to_i }
110
+ else
111
+ utc = ActiveSupport::TimeZone["UTC"]
112
+ lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(time_zone) }
113
+ end
114
+
115
+ count =
116
+ begin
117
+ 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] } ]
118
+ rescue NoMethodError
119
+ raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
120
+ end
121
+
122
+ series(count, 0, multiple_groups, reverse)
123
+ end
124
+
125
+ protected
126
+
127
+ def time_zone
128
+ @time_zone ||= begin
129
+ time_zone = options[:time_zone] || Groupdate.time_zone || Time.zone || "Etc/UTC"
130
+ time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone]
131
+ end
132
+ end
133
+
134
+ def week_start
135
+ @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
136
+ end
137
+
138
+ def day_start
139
+ @day_start ||= (options[:day_start] || Groupdate.day_start).to_i
140
+ end
141
+
142
+ def time_range
143
+ @time_range ||= begin
144
+ time_range = options[:range]
145
+ if !time_range and options[:last]
146
+ step = 1.send(field) if 1.respond_to?(field)
147
+ if step
148
+ now = Time.now
149
+ time_range = round_time(now - (options[:last].to_i - 1).send(field))..now
150
+ end
151
+ end
152
+ time_range
153
+ end
154
+ end
155
+
156
+ def series(count, default_value, multiple_groups = false, reverse = false)
157
+ reverse = !reverse if options[:reverse]
158
+
159
+ series =
160
+ case field
161
+ when :day_of_week
162
+ 0..6
163
+ when :hour_of_day
164
+ 0..23
165
+ else
166
+ time_range = self.time_range
167
+ time_range =
168
+ if time_range.is_a?(Range)
169
+ time_range
170
+ else
171
+ # use first and last values
172
+ sorted_keys =
173
+ if multiple_groups
174
+ count.keys.map{|k| k[@group_index] }.sort
175
+ else
176
+ count.keys.sort
177
+ end
178
+ sorted_keys.first..sorted_keys.last
179
+ end
180
+
181
+ if time_range.first
182
+ series = [round_time(time_range.first)]
183
+
184
+ step = 1.send(field)
185
+
186
+ while time_range.cover?(series.last + step)
187
+ series << series.last + step
188
+ end
189
+
190
+ if multiple_groups
191
+ keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
192
+ series = series.reverse if reverse
193
+ keys.flat_map do |k|
194
+ series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
195
+ end
196
+ else
197
+ series
198
+ end
199
+ else
200
+ []
201
+ end
202
+ end
203
+
204
+ # reversed above if multiple groups
205
+ if !multiple_groups and reverse
206
+ series = series.to_a.reverse
207
+ end
208
+
209
+ key_format =
210
+ if options[:format]
211
+ if options[:format].respond_to?(:call)
212
+ options[:format]
213
+ else
214
+ sunday = time_zone.parse("2014-03-02 00:00:00")
215
+ lambda do |key|
216
+ case field
217
+ when :hour_of_day
218
+ key = sunday + key.hours + day_start.hours
219
+ when :day_of_week
220
+ key = sunday + key.days
221
+ end
222
+ key.strftime(options[:format].to_s)
223
+ end
224
+ end
225
+ else
226
+ lambda{|k| k }
227
+ end
228
+
229
+ Hash[series.map do |k|
230
+ [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]
231
+ end]
232
+ end
233
+
234
+ def round_time(time)
235
+ time = time.to_time.in_time_zone(time_zone) - day_start.hours
236
+
237
+ time =
238
+ case field
239
+ when :second
240
+ time.change(:usec => 0)
241
+ when :minute
242
+ time.change(:sec => 0)
243
+ when :hour
244
+ time.change(:min => 0)
245
+ when :day
246
+ time.beginning_of_day
247
+ when :week
248
+ # same logic as MySQL group
249
+ weekday = (time.wday - 1) % 7
250
+ (time - ((7 - week_start + weekday) % 7).days).midnight
251
+ when :month
252
+ time.beginning_of_month
253
+ when :year
254
+ time.beginning_of_year
255
+ when :hour_of_day
256
+ time.hour
257
+ when :day_of_week
258
+ (7 - week_start + ((time.wday - 1) % 7) % 7)
259
+ else
260
+ raise "Invalid field"
261
+ end
262
+
263
+ time.is_a?(Time) ? time + day_start.hours : time
264
+ end
265
+
266
+ end
267
+ end
@@ -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
@@ -1,86 +1,16 @@
1
- require "groupdate/order_hack"
2
- require "groupdate/series"
3
- require "active_record"
4
-
5
1
  module Groupdate
6
2
  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|
3
+
4
+ Groupdate::FIELDS.each do |field|
10
5
  define_method :"group_by_#{field}" do |*args|
11
6
  args = args.dup
12
7
  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
8
+ options[:time_zone] ||= args[1] unless args[1].nil?
9
+ options[:range] ||= args[2] unless args[2].nil?
30
10
 
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
11
+ Groupdate::Magic.new(field, options).relation(args[0], self)
83
12
  end
84
13
  end
14
+
85
15
  end
86
16
  end
@@ -1,189 +1,27 @@
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, *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
- relation =
30
- if time_range.is_a?(Range)
31
- # doesn't matter whether we include the end of a ... range - it will be excluded later
32
- @relation.where("#{@column} >= ? AND #{@column} <= ?", time_range.first, time_range.last)
33
- else
34
- @relation.where("#{@column} IS NOT NULL")
35
- end
36
-
37
- # undo reverse since we do not want this to appear in the query
38
- reverse = relation.reverse_order_value
39
- if reverse
40
- relation = relation.reverse_order
41
- end
42
- order = relation.order_values.first
43
- if order.is_a?(String)
44
- parts = order.split(" ")
45
- reverse_order = (parts.size == 2 && parts[0] == @field && parts[1].to_s.downcase == "desc")
46
- reverse = !reverse if reverse_order
47
- end
48
-
49
- multiple_groups = relation.group_values.size > 1
50
-
51
- cast_method =
52
- case @field
53
- when "day_of_week", "hour_of_day"
54
- lambda{|k| k.to_i }
55
- else
56
- lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(@time_zone) }
57
- end
58
-
59
- count =
60
- begin
61
- 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] } ]
62
- rescue NoMethodError
63
- raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
64
- end
65
-
66
- series =
67
- case @field
68
- when "day_of_week"
69
- 0..6
70
- when "hour_of_day"
71
- 0..23
72
- else
73
- time_range =
74
- if time_range.is_a?(Range)
75
- time_range
76
- else
77
- # use first and last values
78
- sorted_keys =
79
- if multiple_groups
80
- count.keys.map{|k| k[@group_index] }.sort
81
- else
82
- count.keys.sort
83
- end
84
- sorted_keys.first..sorted_keys.last
85
- end
86
-
87
- if time_range.first
88
- series = [round_time(time_range.first)]
89
-
90
- step = 1.send(@field)
91
-
92
- while time_range.cover?(series.last + step)
93
- series << series.last + step
94
- end
95
-
96
- if multiple_groups
97
- keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
98
- series = series.reverse if reverse
99
- keys.flat_map do |k|
100
- series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
101
- end
102
- else
103
- series
104
- end
105
- else
106
- []
107
- end
108
- end
109
-
110
- # reversed above if multiple groups
111
- if !multiple_groups and reverse
112
- series = series.to_a.reverse
113
- end
114
-
115
- key_format =
116
- if @options[:format]
117
- if @options[:format].respond_to?(:call)
118
- @options[:format]
119
- else
120
- sunday = @time_zone.parse("2014-03-02 00:00:00")
121
- lambda do |key|
122
- case @field
123
- when "hour_of_day"
124
- key = sunday + key.hours + @day_start.hours
125
- when "day_of_week"
126
- key = sunday + key.days
127
- end
128
- key.strftime(@options[:format].to_s)
129
- end
130
- end
131
- else
132
- lambda{|k| k }
133
- end
134
-
135
- Hash[series.map do |k|
136
- [multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), count[k] || 0]
137
- end]
138
- end
139
-
140
- def round_time(time)
141
- time = time.to_time.in_time_zone(@time_zone) - @day_start.hours
142
-
143
- time =
144
- case @field
145
- when "second"
146
- time.change(:usec => 0)
147
- when "minute"
148
- time.change(:sec => 0)
149
- when "hour"
150
- time.change(:min => 0)
151
- when "day"
152
- time.beginning_of_day
153
- when "week"
154
- # same logic as MySQL group
155
- weekday = (time.wday - 1) % 7
156
- (time - ((7 - @week_start + weekday) % 7).days).midnight
157
- when "month"
158
- time.beginning_of_month
159
- else # year
160
- time.beginning_of_year
161
- end
162
-
163
- time + @day_start.hours
164
- end
165
-
166
- def clone
167
- Groupdate::Series.new(@relation, @field, @column, @time_zone, @time_range, @week_start, @day_start, @group_index, @options)
168
8
  end
169
9
 
170
10
  # clone to prevent modifying original variables
171
11
  def method_missing(method, *args, &block)
172
12
  # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation/calculations.rb
173
13
  if ActiveRecord::Calculations.method_defined?(method)
174
- clone.perform(method, *args, &block)
14
+ magic.perform(relation, method, *args, &block)
175
15
  elsif @relation.respond_to?(method)
176
- series = clone
177
- series.relation = @relation.send(method, *args, &block)
178
- series
16
+ Groupdate::Series.new(magic, relation.send(method, *args, &block))
179
17
  else
180
18
  super
181
19
  end
182
20
  end
183
21
 
184
22
  def respond_to?(method, include_all = false)
185
- ActiveRecord::Calculations.method_defined?(method) || @relation.respond_to?(method) || super
23
+ ActiveRecord::Calculations.method_defined?(method) || relation.respond_to?(method) || super
186
24
  end
187
25
 
188
- end # Series
26
+ end
189
27
  end
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "2.1.1"
2
+ VERSION = "2.2.0"
3
3
  end
@@ -0,0 +1,21 @@
1
+ require_relative "test_helper"
2
+
3
+ class TestEnumerable < Minitest::Test
4
+ include TestGroupdate
5
+
6
+ def test_enumerable
7
+ user_a = User.new(created_at: utc.parse("2014-01-21"))
8
+ user_b = User.new(created_at: utc.parse("2014-03-14"))
9
+ expected = {
10
+ utc.parse("2014-01-01") => [user_a],
11
+ utc.parse("2014-02-01") => [],
12
+ utc.parse("2014-03-01") => [user_b]
13
+ }
14
+ assert_equal expected, [user_a, user_b].group_by_month(&:created_at)
15
+ end
16
+
17
+ def call_method(method, field, options)
18
+ Hash[ User.all.to_a.send(:"group_by_#{method}", options){|u| u.send(field) }.map{|k, v| [k, v.size] } ]
19
+ end
20
+
21
+ end
@@ -1,6 +1,6 @@
1
1
  require_relative "test_helper"
2
2
 
3
- class TestMysql < Minitest::Unit::TestCase
3
+ class TestMysql < Minitest::Test
4
4
  include TestGroupdate
5
5
 
6
6
  def setup
@@ -1,6 +1,6 @@
1
1
  require_relative "test_helper"
2
2
 
3
- class TestPostgresql < Minitest::Unit::TestCase
3
+ class TestPostgresql < Minitest::Test
4
4
  include TestGroupdate
5
5
 
6
6
  def setup
@@ -3,6 +3,7 @@ Bundler.require(:default)
3
3
  require "minitest/autorun"
4
4
  require "minitest/pride"
5
5
  require "logger"
6
+ require "active_record"
6
7
 
7
8
  # TODO determine why this is necessary
8
9
  if RUBY_PLATFORM == "java"
@@ -26,7 +27,7 @@ end
26
27
  ActiveRecord::Migration.create_table :users, :force => true do |t|
27
28
  t.string :name
28
29
  t.integer :score
29
- t.timestamps
30
+ t.timestamp :created_at
30
31
  end
31
32
  end
32
33
 
@@ -386,7 +387,7 @@ module TestGroupdate
386
387
  7.times do |n|
387
388
  expected[n] = n == 3 ? 1 : 0
388
389
  end
389
- assert_equal expected, User.group_by_day_of_week(:created_at, range: true).count
390
+ assert_equal expected, call_method(:day_of_week, :created_at, {})
390
391
  end
391
392
 
392
393
  def test_zeros_hour_of_day
@@ -395,7 +396,7 @@ module TestGroupdate
395
396
  24.times do |n|
396
397
  expected[n] = n == 20 ? 1 : 0
397
398
  end
398
- assert_equal expected, User.group_by_hour_of_day(:created_at, range: true).count
399
+ assert_equal expected, call_method(:hour_of_day, :created_at, {})
399
400
  end
400
401
 
401
402
  def test_zeros_excludes_end
@@ -403,7 +404,7 @@ module TestGroupdate
403
404
  expected = {
404
405
  utc.parse("2013-05-01 00:00:00 UTC") => 0
405
406
  }
406
- assert_equal expected, User.group_by_day(:created_at, range: Time.parse("2013-05-01 00:00:00 UTC")...Time.parse("2013-05-02 00:00:00 UTC")).count
407
+ assert_equal expected, call_method(:day, :created_at, range: Time.parse("2013-05-01 00:00:00 UTC")...Time.parse("2013-05-02 00:00:00 UTC"))
407
408
  end
408
409
 
409
410
  def test_zeros_previous_scope
@@ -419,13 +420,13 @@ module TestGroupdate
419
420
  expected = {
420
421
  utc.parse("2013-05-01 00:00:00 UTC") => 1
421
422
  }
422
- assert_equal expected, User.group_by_day(:created_at, range: DateTime.parse("2013-05-01 00:00:00 UTC")..DateTime.parse("2013-05-01 00:00:00 UTC")).count
423
+ assert_equal expected, call_method(:day, :created_at, range: DateTime.parse("2013-05-01 00:00:00 UTC")..DateTime.parse("2013-05-01 00:00:00 UTC"))
423
424
  end
424
425
 
425
426
  def test_zeros_null_value
426
427
  user = User.create!(name: "Andrew")
427
428
  user.update_column :created_at, nil
428
- assert_equal 0, User.group_by_hour_of_day(:created_at, range: true).count[0]
429
+ assert_equal 0, call_method(:hour_of_day, :created_at, range: true)[0]
429
430
  end
430
431
 
431
432
  def test_zeroes_range_true
@@ -436,7 +437,7 @@ module TestGroupdate
436
437
  utc.parse("2013-05-02 00:00:00 UTC") => 0,
437
438
  utc.parse("2013-05-03 00:00:00 UTC") => 1
438
439
  }
439
- assert_equal expected, User.group_by_day(:created_at, range: true).count
440
+ assert_equal expected, call_method(:day, :created_at, range: true)
440
441
  end
441
442
 
442
443
  # week_start
@@ -469,6 +470,10 @@ module TestGroupdate
469
470
  assert_equal 0, User.group_by_hour_of_day(:created_at).order("hour_of_day desc").reverse_order.count.keys.first
470
471
  end
471
472
 
473
+ def test_order_hour_of_day_reverse_option
474
+ assert_equal 23, call_method(:hour_of_day, :created_at, reverse: true).keys.first
475
+ end
476
+
472
477
  def test_table_name
473
478
  assert_empty User.group_by_day("users.created_at").count
474
479
  end
@@ -481,7 +486,7 @@ module TestGroupdate
481
486
  def test_time_zone
482
487
  create_user "2013-05-01 00:00:00 UTC"
483
488
  time_zone = "Pacific Time (US & Canada)"
484
- assert_equal time_zone, User.group_by_day(:created_at, time_zone: time_zone).count.keys.first.time_zone.name
489
+ assert_equal time_zone, call_method(:day, :created_at, time_zone: time_zone).keys.first.time_zone.name
485
490
  end
486
491
 
487
492
  def test_where_after
@@ -604,7 +609,7 @@ module TestGroupdate
604
609
  # helpers
605
610
 
606
611
  def assert_format(method, expected, format, options = {})
607
- assert_equal expected, User.send(:"group_by_#{method}", :created_at, options.merge(format: format)).count.keys.first
612
+ assert_equal expected, call_method(method, :created_at, options.merge(format: format)).keys.first
608
613
  end
609
614
 
610
615
  def assert_result_time(method, expected, time_str, time_zone = false, options = {})
@@ -618,7 +623,7 @@ module TestGroupdate
618
623
 
619
624
  def result(method, time_str, time_zone = false, options = {})
620
625
  create_user time_str
621
- User.send(:"group_by_#{method}", :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil)).count
626
+ call_method(method, :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil))
622
627
  end
623
628
 
624
629
  def assert_zeros(method, created_at, keys, range_start, range_end, time_zone = nil, options = {})
@@ -627,7 +632,11 @@ module TestGroupdate
627
632
  keys.each_with_index do |key, i|
628
633
  expected[utc.parse(key).in_time_zone(time_zone ? "Pacific Time (US & Canada)" : utc)] = i == 1 ? 1 : 0
629
634
  end
630
- assert_equal expected, User.send(:"group_by_#{method}", :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil, range: Time.parse(range_start)..Time.parse(range_end))).count
635
+ assert_equal expected, call_method(method, :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil, range: Time.parse(range_start)..Time.parse(range_end)))
636
+ end
637
+
638
+ def call_method(method, field, options)
639
+ User.send(:"group_by_#{method}", field, options).count
631
640
  end
632
641
 
633
642
  def create_user(created_at, score = 1)
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: groupdate
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-05-17 00:00:00.000000000 Z
11
+ date: 2014-06-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: activerecord
14
+ name: activesupport
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 3.0.0
19
+ version: '3'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 3.0.0
26
+ version: '3'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -54,6 +54,20 @@ dependencies:
54
54
  version: '0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '5'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activerecord
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - ">="
@@ -109,12 +123,18 @@ files:
109
123
  - README.md
110
124
  - Rakefile
111
125
  - gemfiles/activerecord31.gemfile
126
+ - gemfiles/activerecord32.gemfile
127
+ - gemfiles/activerecord40.gemfile
112
128
  - groupdate.gemspec
113
129
  - lib/groupdate.rb
130
+ - lib/groupdate/active_record.rb
131
+ - lib/groupdate/enumerable.rb
132
+ - lib/groupdate/magic.rb
114
133
  - lib/groupdate/order_hack.rb
115
134
  - lib/groupdate/scopes.rb
116
135
  - lib/groupdate/series.rb
117
136
  - lib/groupdate/version.rb
137
+ - test/enumerable_test.rb
118
138
  - test/mysql_test.rb
119
139
  - test/postgresql_test.rb
120
140
  - test/test_helper.rb
@@ -143,6 +163,7 @@ signing_key:
143
163
  specification_version: 4
144
164
  summary: The simplest way to group temporal data
145
165
  test_files:
166
+ - test/enumerable_test.rb
146
167
  - test/mysql_test.rb
147
168
  - test/postgresql_test.rb
148
169
  - test/test_helper.rb