groupdate2 4.1.5

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.
@@ -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