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