groupdate 4.1.2 → 5.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 +67 -34
- data/CONTRIBUTING.md +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +35 -37
- data/lib/groupdate.rb +7 -4
- 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 +90 -49
- data/lib/groupdate/series_builder.rb +137 -73
- data/lib/groupdate/version.rb +1 -1
- metadata +18 -18
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, :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,22 +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 :hour_of_day
|
35
|
-
["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
|
36
38
|
when :minute_of_hour
|
37
|
-
["
|
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]
|
38
44
|
when :day_of_month
|
39
|
-
["DAYOFMONTH(
|
45
|
+
["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
|
46
|
+
when :day_of_year
|
47
|
+
["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
|
40
48
|
when :month_of_year
|
41
|
-
["MONTH(
|
49
|
+
["MONTH(#{day_start_column})", time_zone, day_start]
|
42
50
|
when :week
|
43
|
-
["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]
|
44
52
|
when :quarter
|
45
|
-
["
|
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]
|
46
56
|
else
|
47
57
|
format =
|
48
58
|
case period
|
@@ -60,43 +70,58 @@ module Groupdate
|
|
60
70
|
"%Y-01-01 00:00:00"
|
61
71
|
end
|
62
72
|
|
63
|
-
["
|
73
|
+
["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
|
64
74
|
end
|
65
75
|
when "PostgreSQL", "PostGIS"
|
76
|
+
day_start_column = "#{column}::timestamptz AT TIME ZONE ? - INTERVAL ?"
|
77
|
+
day_start_interval = "#{day_start} second"
|
78
|
+
|
66
79
|
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
80
|
when :minute_of_hour
|
72
|
-
["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]
|
73
86
|
when :day_of_month
|
74
|
-
["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]
|
75
90
|
when :month_of_year
|
76
|
-
["EXTRACT(MONTH
|
77
|
-
when :week
|
78
|
-
["(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]
|
79
96
|
else
|
80
|
-
|
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
|
81
103
|
end
|
82
104
|
when "SQLite"
|
83
105
|
raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
|
84
106
|
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
107
|
|
87
108
|
if period == :week
|
88
|
-
["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]
|
89
112
|
else
|
90
113
|
format =
|
91
114
|
case period
|
92
|
-
when :hour_of_day
|
93
|
-
"%H"
|
94
115
|
when :minute_of_hour
|
95
116
|
"%M"
|
117
|
+
when :hour_of_day
|
118
|
+
"%H"
|
96
119
|
when :day_of_week
|
97
120
|
"%w"
|
98
121
|
when :day_of_month
|
99
122
|
"%d"
|
123
|
+
when :day_of_year
|
124
|
+
"%j"
|
100
125
|
when :month_of_year
|
101
126
|
"%m"
|
102
127
|
when :second
|
@@ -115,37 +140,40 @@ module Groupdate
|
|
115
140
|
"%Y-01-01 00:00:00 UTC"
|
116
141
|
end
|
117
142
|
|
118
|
-
["strftime(
|
143
|
+
["strftime(?, #{column})", format]
|
119
144
|
end
|
120
145
|
when "Redshift"
|
146
|
+
day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
|
147
|
+
day_start_interval = "#{day_start} second"
|
148
|
+
|
121
149
|
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
150
|
when :minute_of_hour
|
127
|
-
["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]
|
128
156
|
when :day_of_month
|
129
|
-
["EXTRACT(DAY from
|
157
|
+
["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
|
158
|
+
when :day_of_year
|
159
|
+
["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
|
130
160
|
when :month_of_year
|
131
|
-
["EXTRACT(MONTH from
|
161
|
+
["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
|
132
162
|
when :week # start on Sunday, not Redshift default Monday
|
133
163
|
# Redshift does not return timezone information; it
|
134
164
|
# always says it is in UTC time, so we must convert
|
135
165
|
# back to UTC to play properly with the rest of Groupdate.
|
136
|
-
#
|
137
|
-
["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"
|
138
170
|
else
|
139
|
-
["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]
|
140
172
|
end
|
141
173
|
else
|
142
174
|
raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
|
143
175
|
end
|
144
176
|
|
145
|
-
if adapter_name == "MySQL" && period == :week
|
146
|
-
query[0] = "CAST(#{query[0]} AS DATETIME)"
|
147
|
-
end
|
148
|
-
|
149
177
|
clause = @relation.send(:sanitize_sql_array, query)
|
150
178
|
|
151
179
|
# cleaner queries in logs
|
@@ -158,22 +186,35 @@ module Groupdate
|
|
158
186
|
end
|
159
187
|
|
160
188
|
def clean_group_clause_mysql(clause)
|
161
|
-
clause
|
162
|
-
if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
|
163
|
-
clause = clause[9..-21]
|
164
|
-
end
|
165
|
-
clause
|
189
|
+
clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
|
166
190
|
end
|
167
191
|
|
168
192
|
def where_clause
|
169
193
|
if @time_range.is_a?(Range)
|
170
|
-
|
171
|
-
|
194
|
+
if @time_range.end
|
195
|
+
op = @time_range.exclude_end? ? "<" : "<="
|
196
|
+
if @time_range.begin
|
197
|
+
["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
|
198
|
+
else
|
199
|
+
["#{column} #{op} ?", @time_range.last]
|
200
|
+
end
|
201
|
+
else
|
202
|
+
["#{column} >= ?", @time_range.first]
|
203
|
+
end
|
172
204
|
else
|
173
205
|
["#{column} IS NOT NULL"]
|
174
206
|
end
|
175
207
|
end
|
176
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
|
+
|
177
218
|
# resolves eagerly
|
178
219
|
# need to convert both where_clause (easy)
|
179
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,33 +108,54 @@ 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
|
81
115
|
time.month
|
116
|
+
when :day_of_year
|
117
|
+
time.yday
|
82
118
|
else
|
83
119
|
raise Groupdate::Error, "Invalid period"
|
84
120
|
end
|
85
121
|
|
86
|
-
|
87
|
-
|
122
|
+
if day_start != 0 && time.is_a?(Time)
|
123
|
+
time += day_start.seconds
|
124
|
+
time = change_zone.call(time, time_zone)
|
125
|
+
end
|
88
126
|
|
89
127
|
time
|
90
128
|
end
|
91
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
|
+
|
92
141
|
def time_range
|
93
142
|
@time_range ||= begin
|
94
143
|
time_range = options[:range]
|
95
|
-
if
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
144
|
+
# entire range must be Date if begin or end is Date
|
145
|
+
if time_range.is_a?(Range) && (time_range.begin.is_a?(Date) || time_range.end.is_a?(Date))
|
146
|
+
if time_range.begin
|
147
|
+
start = time_zone.parse(time_range.first.to_s)
|
148
|
+
end
|
149
|
+
if time_range.end
|
150
|
+
last = time_zone.parse(time_range.last.to_s)
|
151
|
+
last += 1.day unless time_range.exclude_end?
|
152
|
+
end
|
153
|
+
time_range = Range.new(start, last, true)
|
101
154
|
elsif !time_range && options[:last]
|
102
155
|
if period == :quarter
|
103
156
|
step = 3.months
|
157
|
+
elsif period == :custom
|
158
|
+
step = n_seconds
|
104
159
|
elsif 1.respond_to?(period)
|
105
160
|
step = 1.send(period)
|
106
161
|
else
|
@@ -117,7 +172,8 @@ module Groupdate
|
|
117
172
|
if options[:current] == false
|
118
173
|
round_time(start_at - step)...round_time(now)
|
119
174
|
else
|
120
|
-
|
175
|
+
# extend to end of current period
|
176
|
+
round_time(start_at)...(round_time(now) + step)
|
121
177
|
end
|
122
178
|
end
|
123
179
|
end
|
@@ -141,12 +197,14 @@ module Groupdate
|
|
141
197
|
0..59
|
142
198
|
when :day_of_month
|
143
199
|
1..31
|
200
|
+
when :day_of_year
|
201
|
+
1..366
|
144
202
|
when :month_of_year
|
145
203
|
1..12
|
146
204
|
else
|
147
205
|
time_range = self.time_range
|
148
206
|
time_range =
|
149
|
-
if time_range.is_a?(Range)
|
207
|
+
if time_range.is_a?(Range) && time_range.begin && time_range.end
|
150
208
|
time_range
|
151
209
|
else
|
152
210
|
# use first and last values
|
@@ -157,26 +215,41 @@ module Groupdate
|
|
157
215
|
data.keys.sort
|
158
216
|
end
|
159
217
|
|
160
|
-
|
161
|
-
|
162
|
-
|
218
|
+
if time_range.is_a?(Range)
|
219
|
+
if sorted_keys.any?
|
220
|
+
if time_range.begin
|
221
|
+
time_range.begin..sorted_keys.last
|
222
|
+
else
|
223
|
+
Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
|
224
|
+
end
|
225
|
+
else
|
226
|
+
nil..nil
|
227
|
+
end
|
228
|
+
else
|
229
|
+
tr = sorted_keys.first..sorted_keys.last
|
230
|
+
if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
|
231
|
+
tr = tr.first...round_time(now)
|
232
|
+
end
|
233
|
+
tr
|
163
234
|
end
|
164
|
-
tr
|
165
235
|
end
|
166
236
|
|
167
|
-
if time_range.
|
168
|
-
series = [round_time(time_range.
|
237
|
+
if time_range.begin
|
238
|
+
series = [round_time(time_range.begin)]
|
169
239
|
|
170
240
|
if period == :quarter
|
171
241
|
step = 3.months
|
242
|
+
elsif period == :custom
|
243
|
+
step = n_seconds
|
172
244
|
else
|
173
245
|
step = 1.send(period)
|
174
246
|
end
|
175
247
|
|
176
248
|
last_step = series.last
|
249
|
+
day_start_hour = day_start / 3600
|
177
250
|
loop do
|
178
251
|
next_step = last_step + step
|
179
|
-
next_step = round_time(next_step) if next_step.hour !=
|
252
|
+
next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
|
180
253
|
break unless time_range.cover?(next_step)
|
181
254
|
|
182
255
|
if next_step == last_step
|
@@ -195,34 +268,36 @@ module Groupdate
|
|
195
268
|
end
|
196
269
|
|
197
270
|
def key_format
|
198
|
-
|
199
|
-
|
271
|
+
@key_format ||= begin
|
272
|
+
locale = options[:locale] || I18n.locale
|
273
|
+
use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
|
200
274
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
275
|
+
if options[:format]
|
276
|
+
if options[:format].respond_to?(:call)
|
277
|
+
options[:format]
|
278
|
+
else
|
279
|
+
sunday = time_zone.parse("2014-03-02 00:00:00")
|
280
|
+
lambda do |key|
|
281
|
+
case period
|
282
|
+
when :hour_of_day
|
283
|
+
key = sunday + key.hours + day_start.seconds
|
284
|
+
when :minute_of_hour
|
285
|
+
key = sunday + key.minutes + day_start.seconds
|
286
|
+
when :day_of_week
|
287
|
+
key = sunday + key.days + (week_start + 1).days
|
288
|
+
when :day_of_month
|
289
|
+
key = Date.new(2014, 1, key).to_time
|
290
|
+
when :month_of_year
|
291
|
+
key = Date.new(2014, key, 1).to_time
|
292
|
+
end
|
293
|
+
I18n.localize(key, format: options[:format], locale: locale)
|
218
294
|
end
|
219
|
-
I18n.localize(key, format: options[:format], locale: locale)
|
220
295
|
end
|
296
|
+
elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
|
297
|
+
lambda { |k| k.to_date }
|
298
|
+
else
|
299
|
+
lambda { |k| k }
|
221
300
|
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
301
|
end
|
227
302
|
end
|
228
303
|
|
@@ -242,23 +317,12 @@ module Groupdate
|
|
242
317
|
end
|
243
318
|
end
|
244
319
|
|
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
320
|
def entire_series?(series_default)
|
261
321
|
options.key?(:series) ? options[:series] : series_default
|
262
322
|
end
|
323
|
+
|
324
|
+
def utc
|
325
|
+
@utc ||= ActiveSupport::TimeZone["Etc/UTC"]
|
326
|
+
end
|
263
327
|
end
|
264
328
|
end
|