groupdate 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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