groupdate 4.2.0 → 5.2.1

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
  SHA256:
3
- metadata.gz: 5a10a3b53e404c803b6f78e42b3d4cb43088cf9884db7de2e7a8ffd10d91b786
4
- data.tar.gz: 6b8123a4e90331d58d656f176684660eedeb7722b754d0325feaea87d897c9fd
3
+ metadata.gz: 448e7901aa2448bca591568110960048d18ac0f21faa5c3cae566fc4a6de30c4
4
+ data.tar.gz: ba242e41103ae9d2542ff0509de1f454f58142957f0da190482769a42a152887
5
5
  SHA512:
6
- metadata.gz: eb01ba56326b819e783784dfe09b14ece4bda6ab53bcf2ea7b61a19b94f4d54eaa7ac7b6847cbec3c9e3056d7fac866a4eff83edf1d5b4a0b5a89d9a2ac82d9a
7
- data.tar.gz: 57167fca94dd3b1f61003cb6e3af9164085daaa46da3ff51190d356b1332c3fcf9bac6e26a95cabc803cabd18c1c6d38644163a80ade6b762fea6316ab42a562
6
+ metadata.gz: 91b008f8ee0d9ad419ec042ee2f417b621fba09793b223705a2898fe21083a09527885ead3be2d3b8003427473d1f996938098c9bd7139bcb9bffdad2810734c
7
+ data.tar.gz: d4eb493bdf4b7f40eda2733003263bee49e62f9f69fb6239bec118d1768ce0630c21244e819147ca6b5b1556a9b5e125ed69748e2ee69d3c325f31a2740a6ec5
@@ -1,36 +1,69 @@
1
- ## 4.2.0
1
+ ## 5.2.1 (2020-09-09)
2
+
3
+ - Improved error message for invalid ranges
4
+ - Fixed bug with date string ranges
5
+
6
+ ## 5.2.0 (2020-09-07)
7
+
8
+ - Added warning for non-attribute argument
9
+ - Added support for beginless and endless ranges in `range` option
10
+
11
+ ## 5.1.0 (2020-07-30)
12
+
13
+ - Added `n` option to minute and second for custom durations
14
+
15
+ ## 5.0.0 (2020-02-18)
16
+
17
+ - Added support for `week_start` for SQLite
18
+ - Added support for full weekday names
19
+ - Made `day_start` behavior consistent between Active Record and enumerable
20
+ - Made `last` option extend to end of current period
21
+ - Raise error when `day_start` and `week_start` passed to unsupported methods
22
+ - The `day_start` option no longer applies to shorter periods
23
+ - Fixed `inconsistent time zone info` errors around DST with MySQL and PostgreSQL
24
+ - Improved performance of `format` option
25
+ - Removed deprecated positional arguments for time zone and range
26
+ - Dropped support for `mysql` gem (last release was 2013)
27
+
28
+ ## 4.3.0 (2019-12-26)
29
+
30
+ - Fixed error with empty results in Ruby 2.7
31
+ - Fixed deprecation warnings in Ruby 2.7
32
+ - Deprecated positional arguments for time zone and range
33
+
34
+ ## 4.2.0 (2019-10-28)
2
35
 
3
36
  - Added `day_of_year`
4
37
  - Dropped support for Rails 4.2
5
38
 
6
- ## 4.1.2
39
+ ## 4.1.2 (2019-05-26)
7
40
 
8
41
  - Fixed error with empty data and `current: false`
9
42
  - Fixed error in time zone check for Rails < 5.2
10
43
  - Prevent infinite loop with endless ranges
11
44
 
12
- ## 4.1.1
45
+ ## 4.1.1 (2018-12-11)
13
46
 
14
47
  - Made column resolution consistent with `group`
15
48
  - Added support for `alias_attribute`
16
49
 
17
- ## 4.1.0
50
+ ## 4.1.0 (2018-11-04)
18
51
 
19
52
  - Many performance improvements
20
53
  - Added check for consistent time zone info
21
54
  - Fixed error message for invalid queries with MySQL and SQLite
22
55
  - Fixed issue with enumerable methods ignoring nils
23
56
 
24
- ## 4.0.2
57
+ ## 4.0.2 (2018-10-15)
25
58
 
26
59
  - Make `current` option work without `last`
27
60
  - Fixed default value for `maximum`, `minimum`, and `average` (periods with no results now return `nil` instead of `0`, pass `default_value: 0` for previous behavior)
28
61
 
29
- ## 4.0.1
62
+ ## 4.0.1 (2018-05-03)
30
63
 
31
64
  - Fixed incorrect range with `last` option near time change
32
65
 
33
- ## 4.0.0
66
+ ## 4.0.0 (2018-02-21)
34
67
 
35
68
  - Custom calculation methods are supported by default - `groupdate_calculation_methods` is no longer needed
36
69
 
@@ -42,37 +75,37 @@ Breaking changes
42
75
  - `week_start` now affects `day_of_week`
43
76
  - Removed support for `reverse_order` (was never supported in Rails 5)
44
77
 
45
- ## 3.2.1
78
+ ## 3.2.1 (2018-02-21)
46
79
 
47
80
  - Added `minute_of_hour`
48
81
  - Added support for `unscoped`
49
82
 
50
- ## 3.2.0
83
+ ## 3.2.0 (2017-01-30)
51
84
 
52
85
  - Added limited support for SQLite
53
86
 
54
- ## 3.1.1
87
+ ## 3.1.1 (2016-10-25)
55
88
 
56
89
  - Fixed `current: false`
57
90
  - Fixed `last` with `group_by_quarter`
58
91
  - Raise `ArgumentError` when `last` option is not supported
59
92
 
60
- ## 3.1.0
93
+ ## 3.1.0 (2016-10-22)
61
94
 
62
95
  - Better support for date columns with `time_zone: false`
63
96
  - Better date range handling for `range` option
64
97
 
65
- ## 3.0.2
98
+ ## 3.0.2 (2016-08-09)
66
99
 
67
100
  - Fixed `group_by_period` with associations
68
101
  - Fixed `week_start` option for enumerables
69
102
 
70
- ## 3.0.1
103
+ ## 3.0.1 (2016-07-13)
71
104
 
72
105
  - Added support for Redshift
73
106
  - Fix for infinite loop in certain cases for Rails 5
74
107
 
75
- ## 3.0.0
108
+ ## 3.0.0 (2016-05-30)
76
109
 
77
110
  Breaking changes
78
111
 
@@ -80,16 +113,16 @@ Breaking changes
80
113
  - Array and hash methods no longer return the entire series by default. Use `series: true` for the previous behavior.
81
114
  - The `series: false` option now returns the correct types and order, and plays nicely with other options.
82
115
 
83
- ## 2.5.3
116
+ ## 2.5.3 (2016-04-28)
84
117
 
85
118
  - All tests green with `mysql` gem
86
119
  - Added support for decimal day start
87
120
 
88
- ## 2.5.2
121
+ ## 2.5.2 (2016-02-16)
89
122
 
90
123
  - Added `dates` option to return dates for day, week, month, quarter, and year
91
124
 
92
- ## 2.5.1
125
+ ## 2.5.1 (2016-02-03)
93
126
 
94
127
  - Added `group_by_quarter`
95
128
  - Added `default_value` option
@@ -97,13 +130,13 @@ Breaking changes
97
130
  - Raise `ArgumentError` if no field specified
98
131
  - Added support for ActiveRecord 5 beta
99
132
 
100
- ## 2.5.0
133
+ ## 2.5.0 (2015-09-29)
101
134
 
102
135
  - Added `group_by_period` method
103
136
  - Added `current` option
104
137
  - Raise `ArgumentError` if no block given to enumerable
105
138
 
106
- ## 2.4.0
139
+ ## 2.4.0 (2014-12-28)
107
140
 
108
141
  - Added localization
109
142
  - Added `carry_forward` option
@@ -111,77 +144,77 @@ Breaking changes
111
144
  - Fixed issue w/ Brasilia Summer Time
112
145
  - Fixed issues w/ ActiveRecord 4.2
113
146
 
114
- ## 2.3.0
147
+ ## 2.3.0 (2014-08-31)
115
148
 
116
149
  - Raise error when ActiveRecord::Base.default_timezone is not `:utc`
117
150
  - Added `day_of_month`
118
151
  - Added `month_of_year`
119
152
  - Do not quote column name
120
153
 
121
- ## 2.2.1
154
+ ## 2.2.1 (2014-06-23)
122
155
 
123
156
  - Fixed ActiveRecord 3 associations
124
157
 
125
- ## 2.2.0
158
+ ## 2.2.0 (2014-06-22)
126
159
 
127
160
  - Added support for arrays and hashes
128
161
 
129
- ## 2.1.1
162
+ ## 2.1.1 (2014-05-17)
130
163
 
131
164
  - Fixed format option with multiple groups
132
165
  - Better error message if time zone support is missing for MySQL
133
166
 
134
- ## 2.1.0
167
+ ## 2.1.0 (2014-03-16)
135
168
 
136
169
  - Added last option
137
170
  - Added format option
138
171
 
139
- ## 2.0.4
172
+ ## 2.0.4 (2014-03-12)
140
173
 
141
174
  - Added multiple groups
142
175
  - Added order
143
176
  - Subsequent methods no longer modify relation
144
177
 
145
- ## 2.0.3
178
+ ## 2.0.3 (2014-03-11)
146
179
 
147
180
  - Implemented respond_to?
148
181
 
149
- ## 2.0.2
182
+ ## 2.0.2 (2014-03-11)
150
183
 
151
184
  - where, joins, and includes no longer need to be before the group_by method
152
185
 
153
- ## 2.0.1
186
+ ## 2.0.1 (2014-03-07)
154
187
 
155
188
  - Use time zone instead of UTC for results
156
189
 
157
- ## 2.0.0
190
+ ## 2.0.0 (2014-03-07)
158
191
 
159
192
  - Returns entire series by default
160
193
  - Added day_start option
161
194
  - Better interface
162
195
 
163
- ## 1.0.5
196
+ ## 1.0.5 (2014-03-06)
164
197
 
165
198
  - Added global time_zone option
166
199
 
167
- ## 1.0.4
200
+ ## 1.0.4 (2013-07-20)
168
201
 
169
202
  - Added global week_start option
170
203
  - Fixed bug with NULL values and series
171
204
 
172
- ## 1.0.3
205
+ ## 1.0.3 (2013-07-05)
173
206
 
174
207
  - Fixed deprecation warning when used with will_paginate
175
208
  - Fixed bug with DateTime series
176
209
 
177
- ## 1.0.2
210
+ ## 1.0.2 (2013-06-10)
178
211
 
179
212
  - Added :start option for custom week start for group_by_week
180
213
 
181
- ## 1.0.1
214
+ ## 1.0.1 (2013-06-03)
182
215
 
183
216
  - Fixed series for Rails < 3.2 and MySQL
184
217
 
185
- ## 1.0.0
218
+ ## 1.0.0 (2013-05-15)
186
219
 
187
220
  - First major release
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2019 Andrew Kane
1
+ Copyright (c) 2013-2020 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -32,9 +32,9 @@ For MySQL and SQLite, also follow [these instructions](#additional-instructions)
32
32
  ```ruby
33
33
  User.group_by_day(:created_at).count
34
34
  # {
35
- # Sat, 28 May 2016 => 50,
36
- # Sun, 29 May 2016 => 100,
37
- # Mon, 30 May 2016 => 34
35
+ # Sat, 24 May 2020 => 50,
36
+ # Sun, 25 May 2020 => 100,
37
+ # Mon, 26 May 2020 => 34
38
38
  # }
39
39
  ```
40
40
 
@@ -53,13 +53,14 @@ You can group by:
53
53
 
54
54
  and
55
55
 
56
+ - minute_of_hour
56
57
  - hour_of_day
57
58
  - day_of_week (Sunday = 0, Monday = 1, etc)
58
59
  - day_of_month
59
60
  - day_of_year
60
61
  - month_of_year
61
62
 
62
- Use it anywhere you can use `group`. Works with `count`, `sum`, `minimum`, `maximum`, and `average`. For `median`, check out [ActiveMedian](https://github.com/ankane/active_median).
63
+ Use it anywhere you can use `group`. Works with `count`, `sum`, `minimum`, `maximum`, and `average`. For `median` and `percentile`, check out [ActiveMedian](https://github.com/ankane/active_median).
63
64
 
64
65
  ### Time Zones
65
66
 
@@ -74,9 +75,9 @@ or
74
75
  ```ruby
75
76
  User.group_by_week(:created_at, time_zone: "Pacific Time (US & Canada)").count
76
77
  # {
77
- # Sun, 06 Mar 2016 => 70,
78
- # Sun, 13 Mar 2016 => 54,
79
- # Sun, 20 Mar 2016 => 80
78
+ # Sun, 08 Mar 2020 => 70,
79
+ # Sun, 15 Mar 2020 => 54,
80
+ # Sun, 22 Mar 2020 => 80
80
81
  # }
81
82
  ```
82
83
 
@@ -87,13 +88,13 @@ Time zone objects also work. To see a list of available time zones in Rails, run
87
88
  Weeks start on Sunday by default. Change this with:
88
89
 
89
90
  ```ruby
90
- Groupdate.week_start = :mon # first three letters of day
91
+ Groupdate.week_start = :monday
91
92
  ```
92
93
 
93
94
  or
94
95
 
95
96
  ```ruby
96
- User.group_by_week(:created_at, week_start: :mon).count
97
+ User.group_by_week(:created_at, week_start: :monday).count
97
98
  ```
98
99
 
99
100
  ### Day Start
@@ -147,8 +148,8 @@ To get keys in a different format, use:
147
148
  ```ruby
148
149
  User.group_by_month(:created_at, format: "%b %Y").count
149
150
  # {
150
- # "Jan 2015" => 10
151
- # "Feb 2015" => 12
151
+ # "Jan 2020" => 10
152
+ # "Feb 2020" => 12
152
153
  # }
153
154
  ```
154
155
 
@@ -193,6 +194,14 @@ User.group_by_period(params[:period], :created_at, permit: ["day", "week"]).coun
193
194
 
194
195
  Raises an `ArgumentError` for unpermitted periods.
195
196
 
197
+ ### Custom Duration
198
+
199
+ To group by a specific number of minutes or seconds, use:
200
+
201
+ ```ruby
202
+ User.group_by_minute(:created_at, n: 10).count # 10 minutes
203
+ ```
204
+
196
205
  ### Date Columns
197
206
 
198
207
  If grouping on date columns which don’t need time zone conversion, use:
@@ -226,23 +235,23 @@ Supports the same options as above
226
235
  users.group_by_day(time_zone: time_zone) { |u| u.created_at }
227
236
  ```
228
237
 
229
- Count
238
+ Get the entire series with:
230
239
 
231
240
  ```ruby
232
- users.group_by_day { |u| u.created_at }.map { |k, v| [k, v.size] }.to_h
241
+ users.group_by_day(series: true) { |u| u.created_at }
233
242
  ```
234
243
 
235
- Get the entire series with:
244
+ Count
236
245
 
237
246
  ```ruby
238
- users.group_by_day(series: true) { |u| u.created_at }
247
+ users.group_by_day { |u| u.created_at }.map { |k, v| [k, v.count] }.to_h
239
248
  ```
240
249
 
241
250
  ## Additional Instructions
242
251
 
243
252
  ### For MySQL
244
253
 
245
- [Time zone support](https://dev.mysql.com/doc/refman/5.7/en/time-zone-support.html) must be installed on the server.
254
+ [Time zone support](https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html) must be installed on the server.
246
255
 
247
256
  ```sh
248
257
  mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql
@@ -263,7 +272,7 @@ It should return the time instead of `NULL`.
263
272
  Groupdate has limited support for SQLite.
264
273
 
265
274
  - No time zone support
266
- - No `day_start` or `week_start` options
275
+ - No `day_start` option
267
276
  - No `group_by_quarter` method
268
277
 
269
278
  If your application’s time zone is set to something other than `Etc/UTC` (the default), create an initializer with:
@@ -274,36 +283,18 @@ Groupdate.time_zone = false
274
283
 
275
284
  ## Upgrading
276
285
 
277
- ### 4.0
278
-
279
- Groupdate 4.0 brings a number of improvements. Here are a few to be aware of:
280
-
281
- - `group_by` methods return an `ActiveRecord::Relation` instead of a `Groupdate::Series`
282
- - Invalid options now throw an `ArgumentError`
283
- - `week_start` now affects `day_of_week`
284
- - Custom calculation methods are supported by default
285
-
286
- ### 3.0
286
+ ### 5.0
287
287
 
288
- Groupdate 3.0 brings a number of improvements. Here are a few to be aware of:
288
+ Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
289
289
 
290
- - `Date` objects are now returned for day, week, month, quarter, and year by default. Use `dates: false` for the previous behavior, or change this globally with `Groupdate.dates = false`.
291
- - Array and hash methods no longer return the entire series by default. Use `series: true` for the previous behavior.
292
- - The `series: false` option now returns the correct type and order, and plays nicely with other options.
293
-
294
- ### 2.0
295
-
296
- Groupdate 2.0 brings a number of improvements. Here are two things to be aware of:
297
-
298
- - the entire series is returned by default
299
- - `ActiveSupport::TimeWithZone` keys are now returned for every database adapter - adapters previously returned `Time` or `String` keys
290
+ - The `week_start` option is now supported for SQLite
291
+ - The `day_start` option is now consistent between Active Record and enumerable
292
+ - Deprecated positional arguments for time zone and range have been removed
300
293
 
301
294
  ## History
302
295
 
303
296
  View the [changelog](https://github.com/ankane/groupdate/blob/master/CHANGELOG.md)
304
297
 
305
- Groupdate follows [Semantic Versioning](https://semver.org/)
306
-
307
298
  ## Contributing
308
299
 
309
300
  Everyone is encouraged to help improve this project. Here are a few ways you can help:
@@ -1,9 +1,12 @@
1
+ # dependencies
1
2
  require "active_support/core_ext/module/attribute_accessors"
2
3
  require "active_support/time"
3
- require "groupdate/version"
4
+
5
+ # modules
6
+ require "groupdate/magic"
4
7
  require "groupdate/relation_builder"
5
8
  require "groupdate/series_builder"
6
- require "groupdate/magic"
9
+ require "groupdate/version"
7
10
 
8
11
  module Groupdate
9
12
  class Error < RuntimeError; end
@@ -12,7 +15,7 @@ module Groupdate
12
15
  METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
13
16
 
14
17
  mattr_accessor :week_start, :day_start, :time_zone, :dates
15
- self.week_start = :sun
18
+ self.week_start = :sunday
16
19
  self.day_start = 0
17
20
  self.dates = true
18
21
 
@@ -1,31 +1,25 @@
1
1
  module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
- define_method :"group_by_#{period}" do |*args, &block|
3
+ define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- Groupdate::Magic::Enumerable.group_by(self, period, args[0] || {}, &block)
5
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
+ Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
6
7
  elsif respond_to?(:scoping)
7
- scoping { @klass.send(:"group_by_#{period}", *args, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
8
9
  else
9
10
  raise ArgumentError, "no block given"
10
11
  end
11
12
  end
12
13
  end
13
14
 
14
- def group_by_period(*args, &block)
15
+ def group_by_period(period, *args, **options, &block)
15
16
  if block || !respond_to?(:scoping)
16
- period = args[0]
17
- options = args[1] || {}
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
18
 
19
- options = options.dup
20
- # to_sym is unsafe on user input, so convert to strings
21
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
22
- if permitted_periods.include?(period.to_s)
23
- send("group_by_#{period}", options, &block)
24
- else
25
- raise ArgumentError, "Unpermitted period"
26
- end
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
27
21
  else
28
- scoping { @klass.send(:group_by_period, *args, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
29
23
  end
30
24
  end
31
25
  end
@@ -2,18 +2,52 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
- attr_accessor :period, :options, :group_index
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
7
+ attr_accessor :period, :options, :group_index, :n_seconds
6
8
 
7
9
  def initialize(period:, **options)
8
10
  @period = period
9
11
  @options = options
10
12
 
11
- unknown_keywords = options.keys - [:day_start, :time_zone, :dates, :series, :week_start, :format, :locale, :range, :reverse]
13
+ validate_keywords
14
+ validate_arguments
15
+
16
+ if options[:n]
17
+ raise ArgumentError, "n must be a positive integer" if !options[:n].is_a?(Integer) || options[:n] < 1
18
+ @period = :custom
19
+ @n_seconds = options[:n].to_i
20
+ @n_seconds *= 60 if period == :minute
21
+ end
22
+ end
23
+
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :dates, :series, :format, :locale, :range, :reverse]
26
+
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
29
+ end
30
+
31
+ if %i[day week month quarter year day_of_week hour_of_day day_of_month day_of_year month_of_year].include?(period)
32
+ known_keywords << :day_start
33
+ else
34
+ # prevent Groupdate.day_start from applying
35
+ @day_start = 0
36
+ end
37
+
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
40
+ end
41
+
42
+ unknown_keywords = options.keys - known_keywords
12
43
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
44
+ end
13
45
 
14
- raise Groupdate::Error, "Unrecognized time zone" unless time_zone
15
- raise Groupdate::Error, "Unrecognized :week_start option" if period == :week && !week_start
16
- raise Groupdate::Error, "Cannot use endless range for :range option" if options[:range].is_a?(Range) && !options[:range].end
46
+ def validate_arguments
47
+ # TODO better messages
48
+ raise ArgumentError, "Unrecognized time zone" unless time_zone
49
+ raise ArgumentError, "Unrecognized :week_start option" unless week_start
50
+ raise ArgumentError, ":day_start must be between 0 and 24" if (day_start / 3600) < 0 || (day_start / 3600) >= 24
17
51
  end
18
52
 
19
53
  def time_zone
@@ -25,7 +59,10 @@ module Groupdate
25
59
  end
26
60
 
27
61
  def week_start
28
- @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
62
+ @week_start ||= begin
63
+ v = (options[:week_start] || Groupdate.week_start).to_sym
64
+ DAYS.index(v) || [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index(v)
65
+ end
29
66
  end
30
67
 
31
68
  def day_start
@@ -39,7 +76,8 @@ module Groupdate
39
76
  period: period,
40
77
  time_zone: time_zone,
41
78
  day_start: day_start,
42
- week_start: week_start
79
+ week_start: week_start,
80
+ n_seconds: n_seconds
43
81
  )
44
82
  end
45
83
 
@@ -47,6 +85,11 @@ module Groupdate
47
85
  series_builder.time_range
48
86
  end
49
87
 
88
+ def self.validate_period(period, permit)
89
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
90
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
91
+ end
92
+
50
93
  class Enumerable < Magic
51
94
  def group_by(enum, &_block)
52
95
  group = enum.group_by do |v|
@@ -85,10 +128,10 @@ module Groupdate
85
128
  def cast_method
86
129
  @cast_method ||= begin
87
130
  case period
131
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
132
+ lambda { |k| k.to_i }
88
133
  when :day_of_week
89
134
  lambda { |k| (k.to_i - 1 - week_start) % 7 }
90
- when :hour_of_day, :day_of_month, :day_of_year, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
92
135
  else
93
136
  utc = ActiveSupport::TimeZone["UTC"]
94
137
  lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
@@ -142,7 +185,8 @@ module Groupdate
142
185
  time_zone: magic.time_zone,
143
186
  time_range: magic.time_range,
144
187
  week_start: magic.week_start,
145
- day_start: magic.day_start
188
+ day_start: magic.day_start,
189
+ n_seconds: magic.n_seconds
146
190
  ).generate
147
191
 
148
192
  # add Groupdate info
@@ -1,25 +1,18 @@
1
1
  module Groupdate
2
2
  module QueryMethods
3
3
  Groupdate::PERIODS.each do |period|
4
- define_method :"group_by_#{period}" do |field, time_zone = nil, range = nil, **options|
4
+ define_method :"group_by_#{period}" do |field, **options|
5
5
  Groupdate::Magic::Relation.generate_relation(self,
6
6
  period: period,
7
7
  field: field,
8
- time_zone: time_zone,
9
- range: range,
10
8
  **options
11
9
  )
12
10
  end
13
11
  end
14
12
 
15
13
  def group_by_period(period, field, permit: nil, **options)
16
- # to_sym is unsafe on user input, so convert to strings
17
- permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
18
- if permitted_periods.include?(period.to_s)
19
- send("group_by_#{period}", field, **options)
20
- else
21
- raise ArgumentError, "Unpermitted period"
22
- end
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
23
16
  end
24
17
  end
25
18
  end
@@ -1,8 +1,11 @@
1
1
  module Groupdate
2
2
  class RelationBuilder
3
- attr_reader :period, :column, :day_start, :week_start
3
+ attr_reader :period, :column, :day_start, :week_start, :n_seconds
4
+
5
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:)
6
+ # very important
7
+ validate_column(column)
4
8
 
5
- def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:)
6
9
  @relation = relation
7
10
  @column = resolve_column(relation, column)
8
11
  @period = period
@@ -10,6 +13,7 @@ module Groupdate
10
13
  @time_range = time_range
11
14
  @week_start = week_start
12
15
  @day_start = day_start
16
+ @n_seconds = n_seconds
13
17
 
14
18
  if relation.default_timezone == :local
15
19
  raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
@@ -27,24 +31,28 @@ module Groupdate
27
31
  adapter_name = @relation.connection.adapter_name
28
32
  query =
29
33
  case adapter_name
30
- when "MySQL", "Mysql2", "Mysql2Spatial", 'Mysql2Rgeo'
34
+ when "Mysql2", "Mysql2Spatial", "Mysql2Rgeo"
35
+ day_start_column = "CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL ? second"
36
+
31
37
  case period
32
- when :day_of_week
33
- ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1", time_zone]
34
- when :day_of_year
35
- ["DAYOFYEAR(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
36
- when :hour_of_day
37
- ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
38
38
  when :minute_of_hour
39
- ["(EXTRACT(MINUTE from CONVERT_TZ(#{column}, '+00:00', ?)))", time_zone]
39
+ ["MINUTE(#{day_start_column})", time_zone, day_start]
40
+ when :hour_of_day
41
+ ["HOUR(#{day_start_column})", time_zone, day_start]
42
+ when :day_of_week
43
+ ["DAYOFWEEK(#{day_start_column}) - 1", time_zone, day_start]
40
44
  when :day_of_month
41
- ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
45
+ ["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
46
+ when :day_of_year
47
+ ["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
42
48
  when :month_of_year
43
- ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
49
+ ["MONTH(#{day_start_column})", time_zone, day_start]
44
50
  when :week
45
- ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} second)) % 7) DAY) - INTERVAL #{day_start} second, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} second, ?, '+00:00')", time_zone, time_zone, time_zone]
51
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column} - INTERVAL ((? + DAYOFWEEK(#{day_start_column})) % 7) DAY, '%Y-%m-%d 00:00:00') + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, 12 - week_start, time_zone, day_start, day_start, time_zone]
46
52
  when :quarter
47
- ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(DATE(CONCAT(EXTRACT(YEAR FROM CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)), '-', LPAD(1 + 3 * (QUARTER(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1), 2, '00'), '-01')), '%Y-%m-%d %H:%i:%S'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone, time_zone]
53
+ ["CONVERT_TZ(DATE_FORMAT(DATE(CONCAT(YEAR(#{day_start_column}), '-', LPAD(1 + 3 * (QUARTER(#{day_start_column}) - 1), 2, '00'), '-01')), '%Y-%m-%d %H:%i:%S') + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, time_zone, day_start, day_start, time_zone]
54
+ when :custom
55
+ ["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
48
56
  else
49
57
  format =
50
58
  case period
@@ -62,49 +70,60 @@ module Groupdate
62
70
  "%Y-01-01 00:00:00"
63
71
  end
64
72
 
65
- ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone]
73
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
66
74
  end
67
75
  when "PostgreSQL", "PostGIS"
76
+ day_start_column = "#{column}::timestamptz AT TIME ZONE ? - INTERVAL ?"
77
+ day_start_interval = "#{day_start} second"
78
+
68
79
  case period
69
- when :day_of_week
70
- ["EXTRACT(DOW from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
71
- when :day_of_year
72
- ["EXTRACT(DOY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
73
- when :hour_of_day
74
- ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
75
80
  when :minute_of_hour
76
- ["EXTRACT(MINUTE from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
81
+ ["EXTRACT(MINUTE FROM #{day_start_column})::integer", time_zone, day_start_interval]
82
+ when :hour_of_day
83
+ ["EXTRACT(HOUR FROM #{day_start_column})::integer", time_zone, day_start_interval]
84
+ when :day_of_week
85
+ ["EXTRACT(DOW FROM #{day_start_column})::integer", time_zone, day_start_interval]
77
86
  when :day_of_month
78
- ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
87
+ ["EXTRACT(DAY FROM #{day_start_column})::integer", time_zone, day_start_interval]
88
+ when :day_of_year
89
+ ["EXTRACT(DOY FROM #{day_start_column})::integer", time_zone, day_start_interval]
79
90
  when :month_of_year
80
- ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
81
- when :week # start on Sunday, not PostgreSQL default Monday
82
- ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
91
+ ["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
92
+ when :week
93
+ ["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?) AT TIME ZONE ?", time_zone, day_start_interval, 13 - week_start, time_zone, day_start_interval, day_start_interval, time_zone]
94
+ when :custom
95
+ ["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
83
96
  else
84
- ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
97
+ if day_start == 0
98
+ # prettier
99
+ ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
100
+ else
101
+ ["(DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?) AT TIME ZONE ?", period, time_zone, day_start_interval, day_start_interval, time_zone]
102
+ end
85
103
  end
86
104
  when "SQLite"
87
105
  raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
88
106
  raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
89
- raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
90
107
 
91
108
  if period == :week
92
- ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
109
+ ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
110
+ elsif period == :custom
111
+ ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
93
112
  else
94
113
  format =
95
114
  case period
96
- when :hour_of_day
97
- "%H"
98
115
  when :minute_of_hour
99
116
  "%M"
117
+ when :hour_of_day
118
+ "%H"
100
119
  when :day_of_week
101
120
  "%w"
102
121
  when :day_of_month
103
122
  "%d"
104
- when :month_of_year
105
- "%m"
106
123
  when :day_of_year
107
124
  "%j"
125
+ when :month_of_year
126
+ "%m"
108
127
  when :second
109
128
  "%Y-%m-%d %H:%M:%S UTC"
110
129
  when :minute
@@ -121,39 +140,40 @@ module Groupdate
121
140
  "%Y-01-01 00:00:00 UTC"
122
141
  end
123
142
 
124
- ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
143
+ ["strftime(?, #{column})", format]
125
144
  end
126
145
  when "Redshift"
146
+ day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
147
+ day_start_interval = "#{day_start} second"
148
+
127
149
  case period
128
- when :day_of_week
129
- ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
130
- when :hour_of_day
131
- ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
132
150
  when :minute_of_hour
133
- ["EXTRACT(MINUTE from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
151
+ ["EXTRACT(MINUTE from #{day_start_column})::integer", time_zone, day_start_interval]
152
+ when :hour_of_day
153
+ ["EXTRACT(HOUR from #{day_start_column})::integer", time_zone, day_start_interval]
154
+ when :day_of_week
155
+ ["EXTRACT(DOW from #{day_start_column})::integer", time_zone, day_start_interval]
134
156
  when :day_of_month
135
- ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
157
+ ["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
136
158
  when :day_of_year
137
- ["EXTRACT(DOY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
159
+ ["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
138
160
  when :month_of_year
139
- ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
161
+ ["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
140
162
  when :week # start on Sunday, not Redshift default Monday
141
163
  # Redshift does not return timezone information; it
142
164
  # always says it is in UTC time, so we must convert
143
165
  # back to UTC to play properly with the rest of Groupdate.
144
- #
145
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
166
+ week_start_interval = "#{week_start} day"
167
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC('week', #{day_start_column} - INTERVAL ?) + INTERVAL ? + INTERVAL ?)::timestamp", time_zone, time_zone, day_start_interval, week_start_interval, week_start_interval, day_start_interval]
168
+ when :custom
169
+ raise Groupdate::Error, "Not implemented yet"
146
170
  else
147
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
171
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?)::timestamp", time_zone, period, time_zone, day_start_interval, day_start_interval]
148
172
  end
149
173
  else
150
174
  raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
151
175
  end
152
176
 
153
- if adapter_name == "MySQL" && period == :week
154
- query[0] = "CAST(#{query[0]} AS DATETIME)"
155
- end
156
-
157
177
  clause = @relation.send(:sanitize_sql_array, query)
158
178
 
159
179
  # cleaner queries in logs
@@ -166,22 +186,35 @@ module Groupdate
166
186
  end
167
187
 
168
188
  def clean_group_clause_mysql(clause)
169
- clause = clause.gsub("DATE_SUB(#{column}, INTERVAL 0 second)", "#{column}")
170
- if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
171
- clause = clause[9..-21]
172
- end
173
- clause
189
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
174
190
  end
175
191
 
176
192
  def where_clause
177
193
  if @time_range.is_a?(Range)
178
- op = @time_range.exclude_end? ? "<" : "<="
179
- ["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
194
+ if @time_range.end
195
+ op = @time_range.exclude_end? ? "<" : "<="
196
+ if @time_range.begin
197
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.begin, @time_range.end]
198
+ else
199
+ ["#{column} #{op} ?", @time_range.end]
200
+ end
201
+ else
202
+ ["#{column} >= ?", @time_range.begin]
203
+ end
180
204
  else
181
205
  ["#{column} IS NOT NULL"]
182
206
  end
183
207
  end
184
208
 
209
+ # basic version of Active Record disallow_raw_sql!
210
+ # symbol = column (safe), Arel node = SQL (safe), other = untrusted
211
+ def validate_column(column)
212
+ # matches table.column and column
213
+ unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral) || /\A\w+(\.\w+)?\z/i.match(column.to_s)
214
+ warn "[groupdate] Non-attribute argument: #{column}. Use Arel.sql() for known-safe values. This will raise an error in Groupdate 6"
215
+ end
216
+ end
217
+
185
218
  # resolves eagerly
186
219
  # need to convert both where_clause (easy)
187
220
  # and group_clause (not easy) if want to avoid this
@@ -1,29 +1,64 @@
1
1
  module Groupdate
2
2
  class SeriesBuilder
3
- attr_reader :period, :time_zone, :day_start, :week_start, :options
3
+ attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
4
4
 
5
5
  CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
6
 
7
- def initialize(period:, time_zone:, day_start:, week_start:, **options)
7
+ def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
8
  @period = period
9
9
  @time_zone = time_zone
10
10
  @week_start = week_start
11
11
  @day_start = day_start
12
+ @n_seconds = n_seconds
12
13
  @options = options
13
- @round_time = {}
14
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
14
15
  end
15
16
 
16
17
  def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
18
  series = generate_series(data, multiple_groups, group_index)
18
19
  series = handle_multiple(data, series, multiple_groups, group_index)
19
20
 
21
+ verified_data = {}
22
+ series.each do |k|
23
+ verified_data[k] = data.delete(k)
24
+ end
25
+
26
+ # this is a fun one
27
+ # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
28
+ # Other databases and Active Support return the 1st hour (as expected)
29
+ # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
30
+ # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
31
+ # Ruby not good: Time.parse("2013-11-03 01:00:00")
32
+ # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
33
+ # we need to account for this here
34
+ if series_default && CHECK_PERIODS.include?(period)
35
+ data.each do |k, v|
36
+ key = multiple_groups ? k[group_index] : k
37
+ # TODO only do this for PostgreSQL
38
+ # this may mask some inconsistent time zone errors
39
+ # but not sure there's a better approach
40
+ if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
41
+ key -= 1.hour
42
+ if multiple_groups
43
+ k[group_index] = key
44
+ else
45
+ k = key
46
+ end
47
+ verified_data[k] = v
48
+ elsif key != round_time(key)
49
+ # only need to show what database returned since it will cast in Ruby time zone
50
+ raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
51
+ end
52
+ end
53
+ end
54
+
20
55
  unless entire_series?(series_default)
21
- series = series.select { |k| data[k] }
56
+ series = series.select { |k| verified_data[k] }
22
57
  end
23
58
 
24
59
  value = 0
25
60
  result = Hash[series.map do |k|
26
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
61
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
27
62
  key =
28
63
  if multiple_groups
29
64
  k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
@@ -34,20 +69,21 @@ module Groupdate
34
69
  [key, value]
35
70
  end]
36
71
 
37
- # only check for database
38
- # only checks remaining keys to avoid expensive calls to round_time
39
- if series_default && CHECK_PERIODS.include?(period)
40
- check_consistent_time_zone_info(data, multiple_groups, group_index)
41
- end
42
-
43
72
  result
44
73
  end
45
74
 
46
75
  def round_time(time)
76
+ if period == :custom
77
+ return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
78
+ end
79
+
47
80
  time = time.to_time.in_time_zone(time_zone)
48
81
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
82
+ if day_start != 0
83
+ # apply day_start to a time object that's not affected by DST
84
+ time = change_zone.call(time, utc)
85
+ time -= day_start.seconds
86
+ end
51
87
 
52
88
  time =
53
89
  case period
@@ -60,9 +96,7 @@ module Groupdate
60
96
  when :day
61
97
  time.beginning_of_day
62
98
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
99
+ time.beginning_of_week(@week_start_key)
66
100
  when :month
67
101
  time.beginning_of_month
68
102
  when :quarter
@@ -74,7 +108,7 @@ module Groupdate
74
108
  when :minute_of_hour
75
109
  time.min
76
110
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
111
+ time.days_to_week_start(@week_start_key)
78
112
  when :day_of_month
79
113
  time.day
80
114
  when :month_of_year
@@ -85,24 +119,62 @@ module Groupdate
85
119
  raise Groupdate::Error, "Invalid period"
86
120
  end
87
121
 
88
- # only if day_start != 0 for performance
89
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
122
+ if day_start != 0 && time.is_a?(Time)
123
+ time += day_start.seconds
124
+ time = change_zone.call(time, time_zone)
125
+ end
90
126
 
91
127
  time
92
128
  end
93
129
 
130
+ def change_zone
131
+ @change_zone ||= begin
132
+ if ActiveSupport::VERSION::STRING >= "5.2"
133
+ ->(time, zone) { time.change(zone: zone) }
134
+ else
135
+ # TODO make more efficient
136
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
137
+ end
138
+ end
139
+ end
140
+
94
141
  def time_range
95
142
  @time_range ||= begin
96
143
  time_range = options[:range]
97
- if time_range.is_a?(Range) && time_range.first.is_a?(Date)
98
- # convert range of dates to range of times
99
- # use parsing instead of in_time_zone due to Rails < 4
100
- last = time_zone.parse(time_range.last.to_s)
101
- last += 1.day unless time_range.exclude_end?
102
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
144
+
145
+ if time_range.is_a?(Range)
146
+ # check types
147
+ [time_range.begin, time_range.end].each do |v|
148
+ case v
149
+ when nil, Date, Time
150
+ # good
151
+ when String
152
+ # TODO raise error in Groupdate 6
153
+ warn "[groupdate] Range bounds should be Date or Time, not #{v.class.name}. This will raise an error in Groupdate 6"
154
+ break
155
+ else
156
+ raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
157
+ end
158
+ end
159
+
160
+ start = time_range.begin
161
+ start = start.in_time_zone(time_zone) if start
162
+
163
+ exclude_end = time_range.exclude_end?
164
+
165
+ finish = time_range.end
166
+ finish = finish.in_time_zone(time_zone) if finish
167
+ if time_range.end.is_a?(Date) && !exclude_end
168
+ finish += 1.day
169
+ exclude_end = true
170
+ end
171
+
172
+ time_range = Range.new(start, finish, exclude_end)
103
173
  elsif !time_range && options[:last]
104
174
  if period == :quarter
105
175
  step = 3.months
176
+ elsif period == :custom
177
+ step = n_seconds
106
178
  elsif 1.respond_to?(period)
107
179
  step = 1.send(period)
108
180
  else
@@ -119,7 +191,8 @@ module Groupdate
119
191
  if options[:current] == false
120
192
  round_time(start_at - step)...round_time(now)
121
193
  else
122
- round_time(start_at)..now
194
+ # extend to end of current period
195
+ round_time(start_at)...(round_time(now) + step)
123
196
  end
124
197
  end
125
198
  end
@@ -150,7 +223,7 @@ module Groupdate
150
223
  else
151
224
  time_range = self.time_range
152
225
  time_range =
153
- if time_range.is_a?(Range)
226
+ if time_range.is_a?(Range) && time_range.begin && time_range.end
154
227
  time_range
155
228
  else
156
229
  # use first and last values
@@ -161,26 +234,41 @@ module Groupdate
161
234
  data.keys.sort
162
235
  end
163
236
 
164
- tr = sorted_keys.first..sorted_keys.last
165
- if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
166
- tr = tr.first...round_time(now)
237
+ if time_range.is_a?(Range)
238
+ if sorted_keys.any?
239
+ if time_range.begin
240
+ time_range.begin..sorted_keys.last
241
+ else
242
+ Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
243
+ end
244
+ else
245
+ nil..nil
246
+ end
247
+ else
248
+ tr = sorted_keys.first..sorted_keys.last
249
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
250
+ tr = tr.first...round_time(now)
251
+ end
252
+ tr
167
253
  end
168
- tr
169
254
  end
170
255
 
171
- if time_range.first
172
- series = [round_time(time_range.first)]
256
+ if time_range.begin
257
+ series = [round_time(time_range.begin)]
173
258
 
174
259
  if period == :quarter
175
260
  step = 3.months
261
+ elsif period == :custom
262
+ step = n_seconds
176
263
  else
177
264
  step = 1.send(period)
178
265
  end
179
266
 
180
267
  last_step = series.last
268
+ day_start_hour = day_start / 3600
181
269
  loop do
182
270
  next_step = last_step + step
183
- next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
271
+ next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
184
272
  break unless time_range.cover?(next_step)
185
273
 
186
274
  if next_step == last_step
@@ -199,34 +287,36 @@ module Groupdate
199
287
  end
200
288
 
201
289
  def key_format
202
- locale = options[:locale] || I18n.locale
203
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
290
+ @key_format ||= begin
291
+ locale = options[:locale] || I18n.locale
292
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
204
293
 
205
- if options[:format]
206
- if options[:format].respond_to?(:call)
207
- options[:format]
208
- else
209
- sunday = time_zone.parse("2014-03-02 00:00:00")
210
- lambda do |key|
211
- case period
212
- when :hour_of_day
213
- key = sunday + key.hours + day_start.seconds
214
- when :minute_of_hour
215
- key = sunday + key.minutes + day_start.seconds
216
- when :day_of_week
217
- key = sunday + key.days + (week_start + 1).days
218
- when :day_of_month
219
- key = Date.new(2014, 1, key).to_time
220
- when :month_of_year
221
- key = Date.new(2014, key, 1).to_time
294
+ if options[:format]
295
+ if options[:format].respond_to?(:call)
296
+ options[:format]
297
+ else
298
+ sunday = time_zone.parse("2014-03-02 00:00:00")
299
+ lambda do |key|
300
+ case period
301
+ when :hour_of_day
302
+ key = sunday + key.hours + day_start.seconds
303
+ when :minute_of_hour
304
+ key = sunday + key.minutes + day_start.seconds
305
+ when :day_of_week
306
+ key = sunday + key.days + (week_start + 1).days
307
+ when :day_of_month
308
+ key = Date.new(2014, 1, key).to_time
309
+ when :month_of_year
310
+ key = Date.new(2014, key, 1).to_time
311
+ end
312
+ I18n.localize(key, format: options[:format], locale: locale)
222
313
  end
223
- I18n.localize(key, format: options[:format], locale: locale)
224
314
  end
315
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
316
+ lambda { |k| k.to_date }
317
+ else
318
+ lambda { |k| k }
225
319
  end
226
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
227
- lambda { |k| k.to_date }
228
- else
229
- lambda { |k| k }
230
320
  end
231
321
  end
232
322
 
@@ -246,23 +336,12 @@ module Groupdate
246
336
  end
247
337
  end
248
338
 
249
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
250
- keys = data.keys
251
- if multiple_groups
252
- keys.map! { |k| k[group_index] }
253
- keys.uniq!
254
- end
255
-
256
- keys.each do |key|
257
- if key != round_time(key)
258
- # only need to show what database returned since it will cast in Ruby time zone
259
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
260
- end
261
- end
262
- end
263
-
264
339
  def entire_series?(series_default)
265
340
  options.key?(:series) ? options[:series] : series_default
266
341
  end
342
+
343
+ def utc
344
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
345
+ end
267
346
  end
268
347
  end
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "4.2.0"
2
+ VERSION = "5.2.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: groupdate
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.0
4
+ version: 5.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-10-28 00:00:00.000000000 Z
11
+ date: 2020-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -160,7 +160,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
160
160
  - !ruby/object:Gem::Version
161
161
  version: '0'
162
162
  requirements: []
163
- rubygems_version: 3.0.3
163
+ rubygems_version: 3.1.2
164
164
  signing_key:
165
165
  specification_version: 4
166
166
  summary: The simplest way to group temporal data