groupdate2 4.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ module Groupdate
2
+ module QueryMethods
3
+ Groupdate::PERIODS.each do |period|
4
+ define_method :"group_by_#{period}" do |field, time_zone = nil, range = nil, **options|
5
+ Groupdate::Magic::Relation.generate_relation(self,
6
+ period: period,
7
+ field: field,
8
+ time_zone: time_zone,
9
+ range: range,
10
+ **options
11
+ )
12
+ end
13
+ end
14
+
15
+ def group_by_period(period, field, permit: nil, **options)
16
+ # to_sym is unsafe on user input, so convert to strings
17
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
18
+ if permitted_periods.include?(period.to_s)
19
+ send("group_by_#{period}", field, **options)
20
+ else
21
+ raise ArgumentError, "Unpermitted period"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ require "active_support/concern"
2
+
3
+ module Groupdate
4
+ module Relation
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ attr_accessor :groupdate_values
9
+ end
10
+
11
+ def calculate(*args, &block)
12
+ default_value = [:count, :sum].include?(args[0]) ? 0 : nil
13
+ Groupdate.process_result(self, super, default_value: default_value)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,191 @@
1
+ require_relative 'sql_server_group_clause'
2
+
3
+ module Groupdate
4
+ class RelationBuilder
5
+ include SqlServerGroupClause
6
+ attr_reader :period, :column, :day_start, :week_start
7
+
8
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:)
9
+ @relation = relation
10
+ @column = resolve_column(relation, column)
11
+ @period = period
12
+ @time_zone = time_zone
13
+ @time_range = time_range
14
+ @week_start = week_start
15
+ @day_start = day_start
16
+
17
+ if relation.default_timezone == :local
18
+ raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
19
+ end
20
+ end
21
+
22
+ def generate
23
+ @relation.group(group_clause).where(*where_clause)
24
+ end
25
+
26
+ private
27
+
28
+ def group_clause
29
+ time_zone = @time_zone.tzinfo.name
30
+ adapter_name = @relation.connection.adapter_name
31
+ query =
32
+ case adapter_name
33
+ when "SQLServer"
34
+ sql_server_group_clause(time_zone)
35
+ when "MySQL", "Mysql2", "Mysql2Spatial", 'Mysql2Rgeo'
36
+ case period
37
+ when :day_of_week
38
+ ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1", time_zone]
39
+ when :hour_of_day
40
+ ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
41
+ when :minute_of_hour
42
+ ["(EXTRACT(MINUTE from CONVERT_TZ(#{column}, '+00:00', ?)))", time_zone]
43
+ when :day_of_month
44
+ ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
45
+ when :month_of_year
46
+ ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
47
+ when :week
48
+ ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} second)) % 7) DAY) - INTERVAL #{day_start} second, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} second, ?, '+00:00')", time_zone, time_zone, time_zone]
49
+ when :quarter
50
+ ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(DATE(CONCAT(EXTRACT(YEAR FROM CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)), '-', LPAD(1 + 3 * (QUARTER(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1), 2, '00'), '-01')), '%Y-%m-%d %H:%i:%S'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone, time_zone]
51
+ else
52
+ format =
53
+ case period
54
+ when :second
55
+ "%Y-%m-%d %H:%i:%S"
56
+ when :minute
57
+ "%Y-%m-%d %H:%i:00"
58
+ when :hour
59
+ "%Y-%m-%d %H:00:00"
60
+ when :day
61
+ "%Y-%m-%d 00:00:00"
62
+ when :month
63
+ "%Y-%m-01 00:00:00"
64
+ else # year
65
+ "%Y-01-01 00:00:00"
66
+ end
67
+
68
+ ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone]
69
+ end
70
+ when "PostgreSQL", "PostGIS"
71
+ case period
72
+ when :day_of_week
73
+ ["EXTRACT(DOW from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
74
+ when :hour_of_day
75
+ ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
76
+ when :minute_of_hour
77
+ ["EXTRACT(MINUTE from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
78
+ when :day_of_month
79
+ ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
80
+ when :month_of_year
81
+ ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
82
+ when :week # start on Sunday, not PostgreSQL default Monday
83
+ ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
84
+ else
85
+ ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
86
+ end
87
+ when "SQLite"
88
+ raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
89
+ raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
90
+ raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
91
+
92
+ if period == :week
93
+ ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
94
+ else
95
+ format =
96
+ case period
97
+ when :hour_of_day
98
+ "%H"
99
+ when :minute_of_hour
100
+ "%M"
101
+ when :day_of_week
102
+ "%w"
103
+ when :day_of_month
104
+ "%d"
105
+ when :month_of_year
106
+ "%m"
107
+ when :second
108
+ "%Y-%m-%d %H:%M:%S UTC"
109
+ when :minute
110
+ "%Y-%m-%d %H:%M:00 UTC"
111
+ when :hour
112
+ "%Y-%m-%d %H:00:00 UTC"
113
+ when :day
114
+ "%Y-%m-%d 00:00:00 UTC"
115
+ when :month
116
+ "%Y-%m-01 00:00:00 UTC"
117
+ when :quarter
118
+ raise Groupdate::Error, "Quarter not supported for SQLite"
119
+ else # year
120
+ "%Y-01-01 00:00:00 UTC"
121
+ end
122
+
123
+ ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
124
+ end
125
+ when "Redshift"
126
+ case period
127
+ when :day_of_week
128
+ ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
129
+ when :hour_of_day
130
+ ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
131
+ when :minute_of_hour
132
+ ["EXTRACT(MINUTE from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
133
+ when :day_of_month
134
+ ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
135
+ when :month_of_year
136
+ ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
137
+ when :week # start on Sunday, not Redshift default Monday
138
+ # Redshift does not return timezone information; it
139
+ # always says it is in UTC time, so we must convert
140
+ # back to UTC to play properly with the rest of Groupdate.
141
+ #
142
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
143
+ else
144
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
145
+ end
146
+ else
147
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
148
+ end
149
+
150
+ if adapter_name == "MySQL" && period == :week
151
+ query[0] = "CAST(#{query[0]} AS DATETIME)"
152
+ end
153
+
154
+ clause = @relation.send(:sanitize_sql_array, query)
155
+
156
+ # cleaner queries in logs
157
+ clause = clean_group_clause_postgresql(clause)
158
+ clean_group_clause_mysql(clause)
159
+ end
160
+
161
+ def clean_group_clause_postgresql(clause)
162
+ clause.gsub(/ (\-|\+) INTERVAL '0 second'/, "")
163
+ end
164
+
165
+ def clean_group_clause_mysql(clause)
166
+ clause = clause.gsub("DATE_SUB(#{column}, INTERVAL 0 second)", "#{column}")
167
+ if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
168
+ clause = clause[9..-21]
169
+ end
170
+ clause
171
+ end
172
+
173
+ def where_clause
174
+ if @time_range.is_a?(Range)
175
+ op = @time_range.exclude_end? ? "<" : "<="
176
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
177
+ else
178
+ ["#{column} IS NOT NULL"]
179
+ end
180
+ end
181
+
182
+ # resolves eagerly
183
+ # need to convert both where_clause (easy)
184
+ # and group_clause (not easy) if want to avoid this
185
+ def resolve_column(relation, column)
186
+ node = relation.send(:relation).send(:arel_columns, [column]).first
187
+ node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
188
+ relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,264 @@
1
+ module Groupdate
2
+ class SeriesBuilder
3
+ attr_reader :period, :time_zone, :day_start, :week_start, :options
4
+
5
+ CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
+
7
+ def initialize(period:, time_zone:, day_start:, week_start:, **options)
8
+ @period = period
9
+ @time_zone = time_zone
10
+ @week_start = week_start
11
+ @day_start = day_start
12
+ @options = options
13
+ @round_time = {}
14
+ end
15
+
16
+ def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
+ series = generate_series(data, multiple_groups, group_index)
18
+ series = handle_multiple(data, series, multiple_groups, group_index)
19
+
20
+ unless entire_series?(series_default)
21
+ series = series.select { |k| data[k] }
22
+ end
23
+
24
+ value = 0
25
+ result = Hash[series.map do |k|
26
+ value = data.delete(k) || (@options[:carry_forward] && value) || default_value
27
+ key =
28
+ if multiple_groups
29
+ k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
30
+ else
31
+ key_format.call(k)
32
+ end
33
+
34
+ [key, value]
35
+ end]
36
+
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
+ result
44
+ end
45
+
46
+ def round_time(time)
47
+ time = time.to_time.in_time_zone(time_zone)
48
+
49
+ # only if day_start != 0 for performance
50
+ time -= day_start.seconds if day_start != 0
51
+
52
+ time =
53
+ case period
54
+ when :second
55
+ time.change(usec: 0)
56
+ when :minute
57
+ time.change(sec: 0)
58
+ when :hour
59
+ time.change(min: 0)
60
+ when :day
61
+ time.beginning_of_day
62
+ when :week
63
+ # same logic as MySQL group
64
+ weekday = (time.wday - 1) % 7
65
+ (time - ((7 - week_start + weekday) % 7).days).midnight
66
+ when :month
67
+ time.beginning_of_month
68
+ when :quarter
69
+ time.beginning_of_quarter
70
+ when :year
71
+ time.beginning_of_year
72
+ when :hour_of_day
73
+ time.hour
74
+ when :minute_of_hour
75
+ time.min
76
+ when :day_of_week
77
+ (time.wday - 1 - week_start) % 7
78
+ when :day_of_month
79
+ time.day
80
+ when :month_of_year
81
+ time.month
82
+ else
83
+ raise Groupdate::Error, "Invalid period"
84
+ end
85
+
86
+ # only if day_start != 0 for performance
87
+ time += day_start.seconds if day_start != 0 && time.is_a?(Time)
88
+
89
+ time
90
+ end
91
+
92
+ def time_range
93
+ @time_range ||= begin
94
+ time_range = options[:range]
95
+ if time_range.is_a?(Range) && time_range.first.is_a?(Date)
96
+ # convert range of dates to range of times
97
+ # use parsing instead of in_time_zone due to Rails < 4
98
+ last = time_zone.parse(time_range.last.to_s)
99
+ last += 1.day unless time_range.exclude_end?
100
+ time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
101
+ elsif !time_range && options[:last]
102
+ if period == :quarter
103
+ step = 3.months
104
+ elsif 1.respond_to?(period)
105
+ step = 1.send(period)
106
+ else
107
+ raise ArgumentError, "Cannot use last option with #{period}"
108
+ end
109
+ if step
110
+ # loop instead of multiply to change start_at - see #151
111
+ start_at = now
112
+ (options[:last].to_i - 1).times do
113
+ start_at -= step
114
+ end
115
+
116
+ time_range =
117
+ if options[:current] == false
118
+ round_time(start_at - step)...round_time(now)
119
+ else
120
+ round_time(start_at)..now
121
+ end
122
+ end
123
+ end
124
+ time_range
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def now
131
+ @now ||= time_zone.now
132
+ end
133
+
134
+ def generate_series(data, multiple_groups, group_index)
135
+ case period
136
+ when :day_of_week
137
+ 0..6
138
+ when :hour_of_day
139
+ 0..23
140
+ when :minute_of_hour
141
+ 0..59
142
+ when :day_of_month
143
+ 1..31
144
+ when :month_of_year
145
+ 1..12
146
+ else
147
+ time_range = self.time_range
148
+ time_range =
149
+ if time_range.is_a?(Range)
150
+ time_range
151
+ else
152
+ # use first and last values
153
+ sorted_keys =
154
+ if multiple_groups
155
+ data.keys.map { |k| k[group_index] }.sort
156
+ else
157
+ data.keys.sort
158
+ end
159
+
160
+ tr = sorted_keys.first..sorted_keys.last
161
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
162
+ tr = tr.first...round_time(now)
163
+ end
164
+ tr
165
+ end
166
+
167
+ if time_range.first
168
+ series = [round_time(time_range.first)]
169
+
170
+ if period == :quarter
171
+ step = 3.months
172
+ else
173
+ step = 1.send(period)
174
+ end
175
+
176
+ last_step = series.last
177
+ loop do
178
+ next_step = last_step + step
179
+ next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
180
+ break unless time_range.cover?(next_step)
181
+
182
+ if next_step == last_step
183
+ last_step += step
184
+ next
185
+ end
186
+ series << next_step
187
+ last_step = next_step
188
+ end
189
+
190
+ series
191
+ else
192
+ []
193
+ end
194
+ end
195
+ end
196
+
197
+ def key_format
198
+ locale = options[:locale] || I18n.locale
199
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
200
+
201
+ if options[:format]
202
+ if options[:format].respond_to?(:call)
203
+ options[:format]
204
+ else
205
+ sunday = time_zone.parse("2014-03-02 00:00:00")
206
+ lambda do |key|
207
+ case period
208
+ when :hour_of_day
209
+ key = sunday + key.hours + day_start.seconds
210
+ when :minute_of_hour
211
+ key = sunday + key.minutes + day_start.seconds
212
+ when :day_of_week
213
+ key = sunday + key.days + (week_start + 1).days
214
+ when :day_of_month
215
+ key = Date.new(2014, 1, key).to_time
216
+ when :month_of_year
217
+ key = Date.new(2014, key, 1).to_time
218
+ end
219
+ I18n.localize(key, format: options[:format], locale: locale)
220
+ end
221
+ 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
+ end
227
+ end
228
+
229
+ def handle_multiple(data, series, multiple_groups, group_index)
230
+ reverse = options[:reverse]
231
+
232
+ if multiple_groups
233
+ keys = data.keys.map { |k| k[0...group_index] + k[(group_index + 1)..-1] }.uniq
234
+ series = series.to_a.reverse if reverse
235
+ keys.flat_map do |k|
236
+ series.map { |s| k[0...group_index] + [s] + k[group_index..-1] }
237
+ end
238
+ elsif reverse
239
+ series.to_a.reverse
240
+ else
241
+ series
242
+ end
243
+ end
244
+
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} != #{round_time(key)}"
256
+ end
257
+ end
258
+ end
259
+
260
+ def entire_series?(series_default)
261
+ options.key?(:series) ? options[:series] : series_default
262
+ end
263
+ end
264
+ end