groupdate 5.2.2 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +1 -1
- data/README.md +18 -17
- data/lib/groupdate/adapters/base_adapter.rb +9 -28
- data/lib/groupdate/adapters/mysql_adapter.rb +17 -10
- data/lib/groupdate/adapters/postgresql_adapter.rb +9 -7
- data/lib/groupdate/adapters/sqlite_adapter.rb +4 -4
- data/lib/groupdate/magic.rb +44 -2
- data/lib/groupdate/relation.rb +3 -0
- data/lib/groupdate/series_builder.rb +38 -81
- data/lib/groupdate/version.rb +1 -1
- data/lib/groupdate.rb +3 -5
- metadata +6 -7
- data/lib/groupdate/adapters/redshift_adapter.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee8a614ef0b4d869d534df2d0bb972296d26edd2839e430547d54a8ffb22a2b0
|
|
4
|
+
data.tar.gz: f15a380692b7726895a3083a4bd5e013610f6583bce614ede9e2fefd06d30f60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6145ca25662c5df8cab197d570a1bf1b26e7492e0404faba390d4af793992fbb3405c4995136b6818ac0be6a6bc3ff4da2ee8288860fd5c2b9b4fadad036c9e6
|
|
7
|
+
data.tar.gz: aaccec08b13902b51c2eb8180bb5a0807e6d2905fdd5b658a3dfab74dd2b4f2fed96dffc2dc42edcf61fe0a4eb20e21efca6a86ef55402a1f9d594d7eecb77e1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## 6.2.0 (2023-01-29)
|
|
2
|
+
|
|
3
|
+
- Added support for async methods with Active Record 7.1
|
|
4
|
+
|
|
5
|
+
## 6.1.0 (2022-04-05)
|
|
6
|
+
|
|
7
|
+
- Added `expand_range` option
|
|
8
|
+
|
|
9
|
+
## 6.0.1 (2022-01-16)
|
|
10
|
+
|
|
11
|
+
- Fixed incorrect results (error before 6.0) with `includes` with Active Record 6.1+
|
|
12
|
+
|
|
13
|
+
## 6.0.0 (2022-01-15)
|
|
14
|
+
|
|
15
|
+
- Raise `ActiveRecord::UnknownAttributeReference` for non-attribute arguments
|
|
16
|
+
- Raise `ArgumentError` for ranges with string bounds
|
|
17
|
+
- Added `n` option for Redshift
|
|
18
|
+
- Changed SQL to return dates instead of times for day, week, month, quarter, and year
|
|
19
|
+
- Removed `dates` option
|
|
20
|
+
- Dropped support for Ruby < 2.6 and Rails < 5.2
|
|
21
|
+
|
|
22
|
+
## 5.2.4 (2021-12-15)
|
|
23
|
+
|
|
24
|
+
- Simplified queries for Active Record 7 and MySQL
|
|
25
|
+
|
|
26
|
+
## 5.2.3 (2021-12-06)
|
|
27
|
+
|
|
28
|
+
- Fixed error and warnings with Active Record 7
|
|
29
|
+
|
|
1
30
|
## 5.2.2 (2021-02-08)
|
|
2
31
|
|
|
3
32
|
- Added support for `nil..nil` ranges in `range` option
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Supports PostgreSQL, MySQL, and Redshift, plus arrays and hashes (and limited su
|
|
|
22
22
|
Add this line to your application’s Gemfile:
|
|
23
23
|
|
|
24
24
|
```ruby
|
|
25
|
-
gem
|
|
25
|
+
gem "groupdate"
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
For MySQL and SQLite, also follow [these instructions](#additional-instructions).
|
|
@@ -119,6 +119,12 @@ To get a specific time range, use:
|
|
|
119
119
|
User.group_by_day(:created_at, range: 2.weeks.ago.midnight..Time.now).count
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
To expand the range to the start and end of the time period, use:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
User.group_by_day(:created_at, range: 2.weeks.ago..Time.now, expand_range: true).count
|
|
126
|
+
```
|
|
127
|
+
|
|
122
128
|
To get the most recent time periods, use:
|
|
123
129
|
|
|
124
130
|
```ruby
|
|
@@ -210,17 +216,12 @@ If grouping on date columns which don’t need time zone conversion, use:
|
|
|
210
216
|
User.group_by_week(:created_on, time_zone: false).count
|
|
211
217
|
```
|
|
212
218
|
|
|
213
|
-
###
|
|
219
|
+
### Default Scopes
|
|
214
220
|
|
|
215
|
-
If
|
|
221
|
+
If you use Postgres and have a default scope that uses `order`, you may get a `column must appear in the GROUP BY clause` error (just like with Active Record’s `group` method). Remove the `order` scope with:
|
|
216
222
|
|
|
217
223
|
```ruby
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
# check against permitted columns
|
|
221
|
-
raise "Unpermitted column" unless ["column_a", "column_b"].include?(column)
|
|
222
|
-
|
|
223
|
-
User.group_by_day(column).count
|
|
224
|
+
User.unscope(:order).group_by_day(:count).count
|
|
224
225
|
```
|
|
225
226
|
|
|
226
227
|
## Arrays and Hashes
|
|
@@ -244,7 +245,7 @@ users.group_by_day(series: true) { |u| u.created_at }
|
|
|
244
245
|
Count
|
|
245
246
|
|
|
246
247
|
```ruby
|
|
247
|
-
users.group_by_day { |u| u.created_at }.
|
|
248
|
+
users.group_by_day { |u| u.created_at }.to_h { |k, v| [k, v.count] }
|
|
248
249
|
```
|
|
249
250
|
|
|
250
251
|
## Additional Instructions
|
|
@@ -257,8 +258,6 @@ users.group_by_day { |u| u.created_at }.map { |k, v| [k, v.count] }.to_h
|
|
|
257
258
|
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql
|
|
258
259
|
```
|
|
259
260
|
|
|
260
|
-
or copy and paste [these statements](https://gist.githubusercontent.com/ankane/1d6b0022173186accbf0/raw/time_zone_support.sql) into a SQL console.
|
|
261
|
-
|
|
262
261
|
You can confirm it worked with:
|
|
263
262
|
|
|
264
263
|
```sql
|
|
@@ -283,13 +282,15 @@ Groupdate.time_zone = false
|
|
|
283
282
|
|
|
284
283
|
## Upgrading
|
|
285
284
|
|
|
286
|
-
###
|
|
285
|
+
### 6.0
|
|
286
|
+
|
|
287
|
+
Groupdate 6.0 protects against unsafe input by default. For non-attribute arguments, use:
|
|
287
288
|
|
|
288
|
-
|
|
289
|
+
```ruby
|
|
290
|
+
User.group_by_day(Arel.sql(known_safe_value)).count
|
|
291
|
+
```
|
|
289
292
|
|
|
290
|
-
|
|
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
|
|
293
|
+
Also, the `dates` option has been removed.
|
|
293
294
|
|
|
294
295
|
## History
|
|
295
296
|
|
|
@@ -4,11 +4,8 @@ module Groupdate
|
|
|
4
4
|
attr_reader :period, :column, :day_start, :week_start, :n_seconds
|
|
5
5
|
|
|
6
6
|
def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:)
|
|
7
|
-
# very important
|
|
8
|
-
column = validate_column(column)
|
|
9
|
-
|
|
10
7
|
@relation = relation
|
|
11
|
-
@column =
|
|
8
|
+
@column = column
|
|
12
9
|
@period = period
|
|
13
10
|
@time_zone = time_zone
|
|
14
11
|
@time_range = time_range
|
|
@@ -16,8 +13,14 @@ module Groupdate
|
|
|
16
13
|
@day_start = day_start
|
|
17
14
|
@n_seconds = n_seconds
|
|
18
15
|
|
|
19
|
-
if
|
|
20
|
-
|
|
16
|
+
if ActiveRecord::VERSION::MAJOR >= 7
|
|
17
|
+
if ActiveRecord.default_timezone == :local
|
|
18
|
+
raise Groupdate::Error, "ActiveRecord.default_timezone must be :utc to use Groupdate"
|
|
19
|
+
end
|
|
20
|
+
else
|
|
21
|
+
if relation.default_timezone == :local
|
|
22
|
+
raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
|
|
23
|
+
end
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
26
|
|
|
@@ -43,28 +46,6 @@ module Groupdate
|
|
|
43
46
|
["#{column} IS NOT NULL"]
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
|
-
|
|
47
|
-
# basic version of Active Record disallow_raw_sql!
|
|
48
|
-
# symbol = column (safe), Arel node = SQL (safe), other = untrusted
|
|
49
|
-
# matches table.column and column
|
|
50
|
-
def validate_column(column)
|
|
51
|
-
unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
|
|
52
|
-
column = column.to_s
|
|
53
|
-
unless /\A\w+(\.\w+)?\z/i.match(column)
|
|
54
|
-
warn "[groupdate] Non-attribute argument: #{column}. Use Arel.sql() for known-safe values. This will raise an error in Groupdate 6"
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
column
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# resolves eagerly
|
|
61
|
-
# need to convert both where_clause (easy)
|
|
62
|
-
# and group_clause (not easy) if want to avoid this
|
|
63
|
-
def resolve_column(relation, column)
|
|
64
|
-
node = relation.send(:relation).send(:arel_columns, [column]).first
|
|
65
|
-
node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
|
|
66
|
-
relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
|
|
67
|
-
end
|
|
68
49
|
end
|
|
69
50
|
end
|
|
70
51
|
end
|
|
@@ -20,9 +20,21 @@ module Groupdate
|
|
|
20
20
|
when :month_of_year
|
|
21
21
|
["MONTH(#{day_start_column})", time_zone, day_start]
|
|
22
22
|
when :week
|
|
23
|
-
["
|
|
23
|
+
["CAST(DATE_FORMAT(#{day_start_column} - INTERVAL ((? + DAYOFWEEK(#{day_start_column})) % 7) DAY, '%Y-%m-%d') AS DATE)", time_zone, day_start, 12 - week_start, time_zone, day_start]
|
|
24
24
|
when :quarter
|
|
25
|
-
["
|
|
25
|
+
["CAST(CONCAT(YEAR(#{day_start_column}), '-', LPAD(1 + 3 * (QUARTER(#{day_start_column}) - 1), 2, '00'), '-01') AS DATE)", time_zone, day_start, time_zone, day_start]
|
|
26
|
+
when :day, :month, :year
|
|
27
|
+
format =
|
|
28
|
+
case period
|
|
29
|
+
when :day
|
|
30
|
+
"%Y-%m-%d"
|
|
31
|
+
when :month
|
|
32
|
+
"%Y-%m-01"
|
|
33
|
+
else # year
|
|
34
|
+
"%Y-01-01"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
["CAST(DATE_FORMAT(#{day_start_column}, ?) AS DATE)", time_zone, day_start, format]
|
|
26
38
|
when :custom
|
|
27
39
|
["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
|
|
28
40
|
else
|
|
@@ -32,14 +44,8 @@ module Groupdate
|
|
|
32
44
|
"%Y-%m-%d %H:%i:%S"
|
|
33
45
|
when :minute
|
|
34
46
|
"%Y-%m-%d %H:%i:00"
|
|
35
|
-
|
|
47
|
+
else # hour
|
|
36
48
|
"%Y-%m-%d %H:00:00"
|
|
37
|
-
when :day
|
|
38
|
-
"%Y-%m-%d 00:00:00"
|
|
39
|
-
when :month
|
|
40
|
-
"%Y-%m-01 00:00:00"
|
|
41
|
-
else # year
|
|
42
|
-
"%Y-01-01 00:00:00"
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
|
|
@@ -49,7 +55,8 @@ module Groupdate
|
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def clean_group_clause(clause)
|
|
52
|
-
|
|
58
|
+
# zero quoted in Active Record 7+
|
|
59
|
+
clause.gsub(/ (\-|\+) INTERVAL 0 second/, "").gsub(/ (\-|\+) INTERVAL '0' second/, "")
|
|
53
60
|
end
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -21,16 +21,18 @@ module Groupdate
|
|
|
21
21
|
when :month_of_year
|
|
22
22
|
["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
|
|
23
23
|
when :week
|
|
24
|
-
["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?)
|
|
24
|
+
["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?)::date", time_zone, day_start_interval, 13 - week_start, time_zone, day_start_interval, day_start_interval]
|
|
25
25
|
when :custom
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if day_start == 0
|
|
29
|
-
# prettier
|
|
30
|
-
["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
|
|
26
|
+
if @relation.connection.adapter_name == "Redshift"
|
|
27
|
+
["TIMESTAMP 'epoch' + (FLOOR(EXTRACT(EPOCH FROM #{column}::timestamp) / ?) * ?) * INTERVAL '1 second'", n_seconds, n_seconds]
|
|
31
28
|
else
|
|
32
|
-
["(
|
|
29
|
+
["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
|
|
33
30
|
end
|
|
31
|
+
when :day, :month, :quarter, :year
|
|
32
|
+
["DATE_TRUNC(?, #{day_start_column})::date", period, time_zone, day_start_interval]
|
|
33
|
+
else
|
|
34
|
+
# day start is always 0 for seconds, minute, hour
|
|
35
|
+
["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
clean_group_clause(@relation.send(:sanitize_sql_array, query))
|
|
@@ -7,7 +7,7 @@ module Groupdate
|
|
|
7
7
|
|
|
8
8
|
query =
|
|
9
9
|
if period == :week
|
|
10
|
-
["strftime('%Y-%m-%d
|
|
10
|
+
["strftime('%Y-%m-%d', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
|
|
11
11
|
elsif period == :custom
|
|
12
12
|
["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
|
|
13
13
|
else
|
|
@@ -32,13 +32,13 @@ module Groupdate
|
|
|
32
32
|
when :hour
|
|
33
33
|
"%Y-%m-%d %H:00:00 UTC"
|
|
34
34
|
when :day
|
|
35
|
-
"%Y-%m-%d
|
|
35
|
+
"%Y-%m-%d"
|
|
36
36
|
when :month
|
|
37
|
-
"%Y-%m-01
|
|
37
|
+
"%Y-%m-01"
|
|
38
38
|
when :quarter
|
|
39
39
|
raise Groupdate::Error, "Quarter not supported for SQLite"
|
|
40
40
|
else # year
|
|
41
|
-
"%Y-01-01
|
|
41
|
+
"%Y-01-01"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
["strftime(?, #{column})", format]
|
data/lib/groupdate/magic.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Groupdate
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def validate_keywords
|
|
25
|
-
known_keywords = [:time_zone, :
|
|
25
|
+
known_keywords = [:time_zone, :series, :format, :locale, :range, :expand_range, :reverse]
|
|
26
26
|
|
|
27
27
|
if %i[week day_of_week].include?(period)
|
|
28
28
|
known_keywords << :week_start
|
|
@@ -125,6 +125,10 @@ module Groupdate
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def perform(relation, result, default_value:)
|
|
128
|
+
if defined?(ActiveRecord::Promise) && result.is_a?(ActiveRecord::Promise)
|
|
129
|
+
return result.then { |r| perform(relation, r, default_value: default_value) }
|
|
130
|
+
end
|
|
131
|
+
|
|
128
132
|
multiple_groups = relation.group_values.size > 1
|
|
129
133
|
|
|
130
134
|
check_nils(result, multiple_groups, relation)
|
|
@@ -145,6 +149,16 @@ module Groupdate
|
|
|
145
149
|
lambda { |k| k.to_i }
|
|
146
150
|
when :day_of_week
|
|
147
151
|
lambda { |k| (k.to_i - 1 - week_start) % 7 }
|
|
152
|
+
when :day, :week, :month, :quarter, :year
|
|
153
|
+
# TODO keep as date
|
|
154
|
+
if day_start != 0
|
|
155
|
+
day_start_hour = day_start / 3600
|
|
156
|
+
day_start_min = (day_start % 3600) / 60
|
|
157
|
+
day_start_sec = (day_start % 3600) % 60
|
|
158
|
+
lambda { |k| k.in_time_zone(time_zone).change(hour: day_start_hour, min: day_start_min, sec: day_start_sec) }
|
|
159
|
+
else
|
|
160
|
+
lambda { |k| k.in_time_zone(time_zone) }
|
|
161
|
+
end
|
|
148
162
|
else
|
|
149
163
|
utc = ActiveSupport::TimeZone["UTC"]
|
|
150
164
|
lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
|
|
@@ -193,11 +207,15 @@ module Groupdate
|
|
|
193
207
|
adapter = Groupdate.adapters[adapter_name]
|
|
194
208
|
raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
|
|
195
209
|
|
|
210
|
+
# very important
|
|
211
|
+
column = validate_column(field)
|
|
212
|
+
column = resolve_column(relation, column)
|
|
213
|
+
|
|
196
214
|
# generate ActiveRecord relation
|
|
197
215
|
relation =
|
|
198
216
|
adapter.new(
|
|
199
217
|
relation,
|
|
200
|
-
column:
|
|
218
|
+
column: column,
|
|
201
219
|
period: magic.period,
|
|
202
220
|
time_zone: magic.time_zone,
|
|
203
221
|
time_range: magic.time_range,
|
|
@@ -213,6 +231,30 @@ module Groupdate
|
|
|
213
231
|
relation
|
|
214
232
|
end
|
|
215
233
|
|
|
234
|
+
class << self
|
|
235
|
+
# basic version of Active Record disallow_raw_sql!
|
|
236
|
+
# symbol = column (safe), Arel node = SQL (safe), other = untrusted
|
|
237
|
+
# matches table.column and column
|
|
238
|
+
def validate_column(column)
|
|
239
|
+
unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
|
|
240
|
+
column = column.to_s
|
|
241
|
+
unless /\A\w+(\.\w+)?\z/i.match(column)
|
|
242
|
+
raise ActiveRecord::UnknownAttributeReference, "Query method called with non-attribute argument(s): #{column.inspect}. Use Arel.sql() for known-safe values."
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
column
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# resolves eagerly
|
|
249
|
+
# need to convert both where_clause (easy)
|
|
250
|
+
# and group_clause (not easy) if want to avoid this
|
|
251
|
+
def resolve_column(relation, column)
|
|
252
|
+
node = relation.send(:relation).send(:arel_columns, [column]).first
|
|
253
|
+
node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
|
|
254
|
+
relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
216
258
|
# allow any options to keep flexible for future
|
|
217
259
|
def self.process_result(relation, result, **options)
|
|
218
260
|
relation.groupdate_values.reverse.each do |gv|
|
data/lib/groupdate/relation.rb
CHANGED
|
@@ -9,6 +9,9 @@ module Groupdate
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def calculate(*args, &block)
|
|
12
|
+
# prevent calculate from being called twice
|
|
13
|
+
return super if ActiveRecord::VERSION::STRING.to_f >= 6.1 && has_include?(args[1])
|
|
14
|
+
|
|
12
15
|
default_value = [:count, :sum].include?(args[0]) ? 0 : nil
|
|
13
16
|
Groupdate.process_result(self, super, default_value: default_value)
|
|
14
17
|
end
|
|
@@ -2,8 +2,6 @@ module Groupdate
|
|
|
2
2
|
class SeriesBuilder
|
|
3
3
|
attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
|
|
4
4
|
|
|
5
|
-
CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
|
|
6
|
-
|
|
7
5
|
def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
|
|
8
6
|
@period = period
|
|
9
7
|
@time_zone = time_zone
|
|
@@ -23,41 +21,12 @@ module Groupdate
|
|
|
23
21
|
verified_data[k] = data.delete(k)
|
|
24
22
|
end
|
|
25
23
|
|
|
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
|
-
|
|
55
24
|
unless entire_series?(series_default)
|
|
56
25
|
series = series.select { |k| verified_data[k] }
|
|
57
26
|
end
|
|
58
27
|
|
|
59
28
|
value = 0
|
|
60
|
-
result =
|
|
29
|
+
result = series.to_h do |k|
|
|
61
30
|
value = verified_data[k] || (@options[:carry_forward] && value) || default_value
|
|
62
31
|
key =
|
|
63
32
|
if multiple_groups
|
|
@@ -67,7 +36,7 @@ module Groupdate
|
|
|
67
36
|
end
|
|
68
37
|
|
|
69
38
|
[key, value]
|
|
70
|
-
end
|
|
39
|
+
end
|
|
71
40
|
|
|
72
41
|
result
|
|
73
42
|
end
|
|
@@ -81,7 +50,7 @@ module Groupdate
|
|
|
81
50
|
|
|
82
51
|
if day_start != 0
|
|
83
52
|
# apply day_start to a time object that's not affected by DST
|
|
84
|
-
time =
|
|
53
|
+
time = time.change(zone: utc)
|
|
85
54
|
time -= day_start.seconds
|
|
86
55
|
end
|
|
87
56
|
|
|
@@ -121,23 +90,12 @@ module Groupdate
|
|
|
121
90
|
|
|
122
91
|
if day_start != 0 && time.is_a?(Time)
|
|
123
92
|
time += day_start.seconds
|
|
124
|
-
time =
|
|
93
|
+
time = time.change(zone: time_zone)
|
|
125
94
|
end
|
|
126
95
|
|
|
127
96
|
time
|
|
128
97
|
end
|
|
129
98
|
|
|
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
|
-
|
|
141
99
|
def time_range
|
|
142
100
|
@time_range ||= begin
|
|
143
101
|
time_range = options[:range]
|
|
@@ -148,10 +106,6 @@ module Groupdate
|
|
|
148
106
|
case v
|
|
149
107
|
when nil, Date, Time
|
|
150
108
|
# 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
109
|
else
|
|
156
110
|
raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
|
|
157
111
|
end
|
|
@@ -169,32 +123,32 @@ module Groupdate
|
|
|
169
123
|
exclude_end = true
|
|
170
124
|
end
|
|
171
125
|
|
|
126
|
+
if options[:expand_range]
|
|
127
|
+
start = round_time(start) if start
|
|
128
|
+
if finish && !(finish == round_time(finish) && exclude_end)
|
|
129
|
+
finish = round_time(finish) + step
|
|
130
|
+
exclude_end = true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
172
134
|
time_range = Range.new(start, finish, exclude_end)
|
|
173
135
|
elsif !time_range && options[:last]
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
elsif period == :custom
|
|
177
|
-
step = n_seconds
|
|
178
|
-
elsif 1.respond_to?(period)
|
|
179
|
-
step = 1.send(period)
|
|
180
|
-
else
|
|
181
|
-
raise ArgumentError, "Cannot use last option with #{period}"
|
|
182
|
-
end
|
|
183
|
-
if step
|
|
184
|
-
# loop instead of multiply to change start_at - see #151
|
|
185
|
-
start_at = now
|
|
186
|
-
(options[:last].to_i - 1).times do
|
|
187
|
-
start_at -= step
|
|
188
|
-
end
|
|
136
|
+
step = step()
|
|
137
|
+
raise ArgumentError, "Cannot use last option with #{period}" unless step
|
|
189
138
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
# extend to end of current period
|
|
195
|
-
round_time(start_at)...(round_time(now) + step)
|
|
196
|
-
end
|
|
139
|
+
# loop instead of multiply to change start_at - see #151
|
|
140
|
+
start_at = now
|
|
141
|
+
(options[:last].to_i - 1).times do
|
|
142
|
+
start_at -= step
|
|
197
143
|
end
|
|
144
|
+
|
|
145
|
+
time_range =
|
|
146
|
+
if options[:current] == false
|
|
147
|
+
round_time(start_at - step)...round_time(now)
|
|
148
|
+
else
|
|
149
|
+
# extend to end of current period
|
|
150
|
+
round_time(start_at)...(round_time(now) + step)
|
|
151
|
+
end
|
|
198
152
|
end
|
|
199
153
|
time_range
|
|
200
154
|
end
|
|
@@ -256,13 +210,7 @@ module Groupdate
|
|
|
256
210
|
if time_range.begin
|
|
257
211
|
series = [round_time(time_range.begin)]
|
|
258
212
|
|
|
259
|
-
|
|
260
|
-
step = 3.months
|
|
261
|
-
elsif period == :custom
|
|
262
|
-
step = n_seconds
|
|
263
|
-
else
|
|
264
|
-
step = 1.send(period)
|
|
265
|
-
end
|
|
213
|
+
step = step()
|
|
266
214
|
|
|
267
215
|
last_step = series.last
|
|
268
216
|
day_start_hour = day_start / 3600
|
|
@@ -289,7 +237,6 @@ module Groupdate
|
|
|
289
237
|
def key_format
|
|
290
238
|
@key_format ||= begin
|
|
291
239
|
locale = options[:locale] || I18n.locale
|
|
292
|
-
use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
|
|
293
240
|
|
|
294
241
|
if options[:format]
|
|
295
242
|
if options[:format].respond_to?(:call)
|
|
@@ -312,7 +259,7 @@ module Groupdate
|
|
|
312
259
|
I18n.localize(key, format: options[:format], locale: locale)
|
|
313
260
|
end
|
|
314
261
|
end
|
|
315
|
-
elsif [:day, :week, :month, :quarter, :year].include?(period)
|
|
262
|
+
elsif [:day, :week, :month, :quarter, :year].include?(period)
|
|
316
263
|
lambda { |k| k.to_date }
|
|
317
264
|
else
|
|
318
265
|
lambda { |k| k }
|
|
@@ -320,6 +267,16 @@ module Groupdate
|
|
|
320
267
|
end
|
|
321
268
|
end
|
|
322
269
|
|
|
270
|
+
def step
|
|
271
|
+
if period == :quarter
|
|
272
|
+
3.months
|
|
273
|
+
elsif period == :custom
|
|
274
|
+
n_seconds
|
|
275
|
+
elsif 1.respond_to?(period)
|
|
276
|
+
1.send(period)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
323
280
|
def handle_multiple(data, series, multiple_groups, group_index)
|
|
324
281
|
reverse = options[:reverse]
|
|
325
282
|
|
data/lib/groupdate/version.rb
CHANGED
data/lib/groupdate.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# dependencies
|
|
2
|
+
require "active_support"
|
|
2
3
|
require "active_support/core_ext/module/attribute_accessors"
|
|
3
4
|
require "active_support/time"
|
|
4
5
|
|
|
@@ -11,7 +12,6 @@ require "groupdate/version"
|
|
|
11
12
|
require "groupdate/adapters/base_adapter"
|
|
12
13
|
require "groupdate/adapters/mysql_adapter"
|
|
13
14
|
require "groupdate/adapters/postgresql_adapter"
|
|
14
|
-
require "groupdate/adapters/redshift_adapter"
|
|
15
15
|
require "groupdate/adapters/sqlite_adapter"
|
|
16
16
|
|
|
17
17
|
module Groupdate
|
|
@@ -20,10 +20,9 @@ module Groupdate
|
|
|
20
20
|
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]
|
|
21
21
|
METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
|
|
22
22
|
|
|
23
|
-
mattr_accessor :week_start, :day_start, :time_zone
|
|
23
|
+
mattr_accessor :week_start, :day_start, :time_zone
|
|
24
24
|
self.week_start = :sunday
|
|
25
25
|
self.day_start = 0
|
|
26
|
-
self.dates = true
|
|
27
26
|
|
|
28
27
|
# api for gems like ActiveMedian
|
|
29
28
|
def self.process_result(relation, result, **options)
|
|
@@ -45,8 +44,7 @@ module Groupdate
|
|
|
45
44
|
end
|
|
46
45
|
|
|
47
46
|
Groupdate.register_adapter ["Mysql2", "Mysql2Spatial", "Mysql2Rgeo"], Groupdate::Adapters::MySQLAdapter
|
|
48
|
-
Groupdate.register_adapter ["PostgreSQL", "PostGIS"], Groupdate::Adapters::PostgreSQLAdapter
|
|
49
|
-
Groupdate.register_adapter "Redshift", Groupdate::Adapters::RedshiftAdapter
|
|
47
|
+
Groupdate.register_adapter ["PostgreSQL", "PostGIS", "Redshift"], Groupdate::Adapters::PostgreSQLAdapter
|
|
50
48
|
Groupdate.register_adapter "SQLite", Groupdate::Adapters::SQLiteAdapter
|
|
51
49
|
|
|
52
50
|
require "groupdate/enumerable"
|
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: 6.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Kane
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2023-01-30 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: '5'
|
|
19
|
+
version: '5.2'
|
|
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: '5'
|
|
26
|
+
version: '5.2'
|
|
27
27
|
description:
|
|
28
28
|
email: andrew@ankane.org
|
|
29
29
|
executables: []
|
|
@@ -39,7 +39,6 @@ files:
|
|
|
39
39
|
- lib/groupdate/adapters/base_adapter.rb
|
|
40
40
|
- lib/groupdate/adapters/mysql_adapter.rb
|
|
41
41
|
- lib/groupdate/adapters/postgresql_adapter.rb
|
|
42
|
-
- lib/groupdate/adapters/redshift_adapter.rb
|
|
43
42
|
- lib/groupdate/adapters/sqlite_adapter.rb
|
|
44
43
|
- lib/groupdate/enumerable.rb
|
|
45
44
|
- lib/groupdate/magic.rb
|
|
@@ -59,14 +58,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
59
58
|
requirements:
|
|
60
59
|
- - ">="
|
|
61
60
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: '2.
|
|
61
|
+
version: '2.6'
|
|
63
62
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
63
|
requirements:
|
|
65
64
|
- - ">="
|
|
66
65
|
- !ruby/object:Gem::Version
|
|
67
66
|
version: '0'
|
|
68
67
|
requirements: []
|
|
69
|
-
rubygems_version: 3.
|
|
68
|
+
rubygems_version: 3.4.1
|
|
70
69
|
signing_key:
|
|
71
70
|
specification_version: 4
|
|
72
71
|
summary: The simplest way to group temporal data
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
module Groupdate
|
|
2
|
-
module Adapters
|
|
3
|
-
class RedshiftAdapter < BaseAdapter
|
|
4
|
-
def group_clause
|
|
5
|
-
time_zone = @time_zone.tzinfo.name
|
|
6
|
-
day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
|
|
7
|
-
day_start_interval = "#{day_start} second"
|
|
8
|
-
|
|
9
|
-
query =
|
|
10
|
-
case period
|
|
11
|
-
when :minute_of_hour
|
|
12
|
-
["EXTRACT(MINUTE from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
13
|
-
when :hour_of_day
|
|
14
|
-
["EXTRACT(HOUR from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
15
|
-
when :day_of_week
|
|
16
|
-
["EXTRACT(DOW from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
17
|
-
when :day_of_month
|
|
18
|
-
["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
19
|
-
when :day_of_year
|
|
20
|
-
["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
21
|
-
when :month_of_year
|
|
22
|
-
["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
|
|
23
|
-
when :week # start on Sunday, not Redshift default Monday
|
|
24
|
-
# Redshift does not return timezone information; it
|
|
25
|
-
# always says it is in UTC time, so we must convert
|
|
26
|
-
# back to UTC to play properly with the rest of Groupdate.
|
|
27
|
-
week_start_interval = "#{week_start} day"
|
|
28
|
-
["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]
|
|
29
|
-
when :custom
|
|
30
|
-
raise Groupdate::Error, "Not implemented yet"
|
|
31
|
-
else
|
|
32
|
-
["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?)::timestamp", time_zone, period, time_zone, day_start_interval, day_start_interval]
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
@relation.send(:sanitize_sql_array, query)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|