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 +4 -4
- data/CHANGELOG.md +68 -35
- data/LICENSE.txt +1 -1
- data/README.md +31 -40
- data/lib/groupdate.rb +6 -3
- data/lib/groupdate/enumerable.rb +9 -15
- data/lib/groupdate/magic.rb +54 -10
- data/lib/groupdate/query_methods.rb +3 -10
- data/lib/groupdate/relation_builder.rb +89 -56
- data/lib/groupdate/series_builder.rb +152 -73
- data/lib/groupdate/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 448e7901aa2448bca591568110960048d18ac0f21faa5c3cae566fc4a6de30c4
|
4
|
+
data.tar.gz: ba242e41103ae9d2542ff0509de1f454f58142957f0da190482769a42a152887
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 91b008f8ee0d9ad419ec042ee2f417b621fba09793b223705a2898fe21083a09527885ead3be2d3b8003427473d1f996938098c9bd7139bcb9bffdad2810734c
|
7
|
+
data.tar.gz: d4eb493bdf4b7f40eda2733003263bee49e62f9f69fb6239bec118d1768ce0630c21244e819147ca6b5b1556a9b5e125ed69748e2ee69d3c325f31a2740a6ec5
|
data/CHANGELOG.md
CHANGED
@@ -1,36 +1,69 @@
|
|
1
|
-
##
|
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
|
data/LICENSE.txt
CHANGED
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,
|
36
|
-
# Sun,
|
37
|
-
# Mon,
|
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,
|
78
|
-
# Sun,
|
79
|
-
# Sun,
|
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 = :
|
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: :
|
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
|
151
|
-
# "Feb
|
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
|
-
|
238
|
+
Get the entire series with:
|
230
239
|
|
231
240
|
```ruby
|
232
|
-
users.group_by_day { |u| u.created_at }
|
241
|
+
users.group_by_day(series: true) { |u| u.created_at }
|
233
242
|
```
|
234
243
|
|
235
|
-
|
244
|
+
Count
|
236
245
|
|
237
246
|
```ruby
|
238
|
-
users.group_by_day
|
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/
|
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`
|
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
|
-
###
|
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
|
288
|
+
Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
|
289
289
|
|
290
|
-
- `
|
291
|
-
-
|
292
|
-
-
|
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:
|
data/lib/groupdate.rb
CHANGED
@@ -1,9 +1,12 @@
|
|
1
|
+
# dependencies
|
1
2
|
require "active_support/core_ext/module/attribute_accessors"
|
2
3
|
require "active_support/time"
|
3
|
-
|
4
|
+
|
5
|
+
# modules
|
6
|
+
require "groupdate/magic"
|
4
7
|
require "groupdate/relation_builder"
|
5
8
|
require "groupdate/series_builder"
|
6
|
-
require "groupdate/
|
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 = :
|
18
|
+
self.week_start = :sunday
|
16
19
|
self.day_start = 0
|
17
20
|
self.dates = true
|
18
21
|
|
data/lib/groupdate/enumerable.rb
CHANGED
@@ -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
|
-
|
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.
|
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
|
-
|
17
|
-
options = args[1] || {}
|
17
|
+
raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
|
18
18
|
|
19
|
-
|
20
|
-
#
|
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.
|
22
|
+
scoping { @klass.group_by_period(period, *args, **options, &block) }
|
29
23
|
end
|
30
24
|
end
|
31
25
|
end
|
data/lib/groupdate/magic.rb
CHANGED
@@ -2,18 +2,52 @@ require "i18n"
|
|
2
2
|
|
3
3
|
module Groupdate
|
4
4
|
class Magic
|
5
|
-
|
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
|
-
|
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
|
-
|
15
|
-
|
16
|
-
raise
|
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 ||=
|
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,
|
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
|
-
|
17
|
-
|
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 "
|
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
|
-
["
|
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(
|
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(
|
49
|
+
["MONTH(#{day_start_column})", time_zone, day_start]
|
44
50
|
when :week
|
45
|
-
["CONVERT_TZ(DATE_FORMAT(
|
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
|
-
["
|
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
|
-
["
|
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
|
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
|
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
|
81
|
-
when :week
|
82
|
-
["(DATE_TRUNC('
|
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
|
-
|
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('
|
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(
|
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
|
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
|
157
|
+
["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
|
136
158
|
when :day_of_year
|
137
|
-
["EXTRACT(DOY from
|
159
|
+
["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
|
138
160
|
when :month_of_year
|
139
|
-
["EXTRACT(MONTH from
|
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(
|
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(?,
|
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
|
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
|
-
|
179
|
-
|
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
|
-
@
|
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|
|
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 =
|
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
|
-
|
50
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
89
|
-
|
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
|
-
|
98
|
-
|
99
|
-
#
|
100
|
-
|
101
|
-
|
102
|
-
|
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
|
-
|
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
|
-
|
165
|
-
|
166
|
-
|
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.
|
172
|
-
series = [round_time(time_range.
|
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 !=
|
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
|
-
|
203
|
-
|
290
|
+
@key_format ||= begin
|
291
|
+
locale = options[:locale] || I18n.locale
|
292
|
+
use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
|
204
293
|
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
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
|
data/lib/groupdate/version.rb
CHANGED
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
|
+
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:
|
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.
|
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
|