groupdate 3.2.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,195 +2,53 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
- attr_accessor :field, :options
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
6
 
7
- def initialize(field, options)
8
- @field = field
9
- @options = options
10
-
11
- raise Groupdate::Error, "Unrecognized time zone" unless time_zone
7
+ attr_accessor :period, :options, :group_index, :n_seconds
12
8
 
13
- raise Groupdate::Error, "Unrecognized :week_start option" if field == :week && !week_start
14
- end
9
+ def initialize(period:, **options)
10
+ @period = period
11
+ @options = options
15
12
 
16
- def group_by(enum, &_block)
17
- group = enum.group_by { |v| v = yield(v); v ? round_time(v) : nil }
18
- series(group, [], false, false, false)
19
- end
13
+ validate_keywords
14
+ validate_arguments
20
15
 
21
- def relation(column, relation)
22
- if relation.default_timezone == :local
23
- raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
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
24
21
  end
22
+ end
25
23
 
26
- time_zone = self.time_zone.tzinfo.name
27
-
28
- adapter_name = relation.connection.adapter_name
29
- query =
30
- case adapter_name
31
- when "MySQL", "Mysql2", "Mysql2Spatial"
32
- case field
33
- when :day_of_week # Sunday = 0, Monday = 1, etc
34
- # use CONCAT for consistent return type (String)
35
- ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1", time_zone]
36
- when :hour_of_day
37
- ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
38
- when :day_of_month
39
- ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
40
- when :month_of_year
41
- ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
42
- when :week
43
- ["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]
44
- when :quarter
45
- ["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]
46
- else
47
- format =
48
- case field
49
- when :second
50
- "%Y-%m-%d %H:%i:%S"
51
- when :minute
52
- "%Y-%m-%d %H:%i:00"
53
- when :hour
54
- "%Y-%m-%d %H:00:00"
55
- when :day
56
- "%Y-%m-%d 00:00:00"
57
- when :month
58
- "%Y-%m-01 00:00:00"
59
- else # year
60
- "%Y-01-01 00:00:00"
61
- end
62
-
63
- ["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]
64
- end
65
- when "PostgreSQL", "PostGIS"
66
- case field
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
- when :day_of_month
72
- ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
73
- when :month_of_year
74
- ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
75
- when :week # start on Sunday, not PostgreSQL default Monday
76
- ["(DATE_TRUNC('#{field}', (#{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]
77
- else
78
- ["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
79
- end
80
- when "SQLite"
81
- raise Groupdate::Error, "Time zones not supported for SQLite" unless self.time_zone.utc_offset.zero?
82
- raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
83
- raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
84
-
85
- if field == :week
86
- ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
87
- else
88
- format =
89
- case field
90
- when :hour_of_day
91
- "%H"
92
- when :day_of_week
93
- "%w"
94
- when :day_of_month
95
- "%d"
96
- when :month_of_year
97
- "%m"
98
- when :second
99
- "%Y-%m-%d %H:%M:%S UTC"
100
- when :minute
101
- "%Y-%m-%d %H:%M:00 UTC"
102
- when :hour
103
- "%Y-%m-%d %H:00:00 UTC"
104
- when :day
105
- "%Y-%m-%d 00:00:00 UTC"
106
- when :month
107
- "%Y-%m-01 00:00:00 UTC"
108
- when :quarter
109
- raise Groupdate::Error, "Quarter not supported for SQLite"
110
- else # year
111
- "%Y-01-01 00:00:00 UTC"
112
- end
113
-
114
- ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
115
- end
116
- when "Redshift"
117
- case field
118
- when :day_of_week # Sunday = 0, Monday = 1, etc.
119
- ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
120
- when :hour_of_day
121
- ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
122
- when :day_of_month
123
- ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
124
- when :month_of_year
125
- ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
126
- when :week # start on Sunday, not Redshift default Monday
127
- # Redshift does not return timezone information; it
128
- # always says it is in UTC time, so we must convert
129
- # back to UTC to play properly with the rest of Groupdate.
130
- #
131
- ["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, field, time_zone]
132
- else
133
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, field, time_zone]
134
- end
135
- else
136
- raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
137
- end
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :series, :format, :locale, :range, :expand_range, :reverse]
138
26
 
139
- if adapter_name == "MySQL" && field == :week
140
- query[0] = "CAST(#{query[0]} AS DATETIME)"
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
141
29
  end
142
30
 
143
- group = relation.group(Groupdate::OrderHack.new(relation.send(:sanitize_sql_array, query), field, time_zone))
144
- relation =
145
- if time_range.is_a?(Range)
146
- # doesn't matter whether we include the end of a ... range - it will be excluded later
147
- group.where("#{column} >= ? AND #{column} <= ?", time_range.first, time_range.last)
148
- else
149
- group.where("#{column} IS NOT NULL")
150
- end
151
-
152
- # TODO do not change object state
153
- @group_index = group.group_values.size - 1
154
-
155
- Groupdate::Series.new(self, relation)
156
- end
157
-
158
- def perform(relation, method, *args, &block)
159
- # undo reverse since we do not want this to appear in the query
160
- reverse = relation.send(:reverse_order_value)
161
- relation = relation.except(:reverse_order) if reverse
162
- order = relation.order_values.first
163
- if order.is_a?(String)
164
- parts = order.split(" ")
165
- reverse_order = (parts.size == 2 && (parts[0].to_sym == field || (activerecord42? && parts[0] == "#{relation.quoted_table_name}.#{relation.quoted_primary_key}")) && parts[1].to_s.downcase == "desc")
166
- if reverse_order
167
- reverse = !reverse
168
- relation = relation.reorder(relation.order_values[1..-1])
169
- end
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
170
36
  end
171
37
 
172
- multiple_groups = relation.group_values.size > 1
173
-
174
- cast_method =
175
- case field
176
- when :day_of_week, :hour_of_day, :day_of_month, :month_of_year
177
- lambda { |k| k.to_i }
178
- else
179
- utc = ActiveSupport::TimeZone["UTC"]
180
- lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
181
- end
182
-
183
- result = relation.send(method, *args, &block)
184
- missing_time_zone_support = multiple_groups ? (result.keys.first && result.keys.first[@group_index].nil?) : result.key?(nil)
185
- if missing_time_zone_support
186
- raise Groupdate::Error, "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
187
40
  end
188
- result = Hash[result.map { |k, v| [multiple_groups ? k[0...@group_index] + [cast_method.call(k[@group_index])] + k[(@group_index + 1)..-1] : cast_method.call(k), v] }]
189
41
 
190
- series(result, (options.key?(:default_value) ? options[:default_value] : 0), multiple_groups, reverse)
42
+ unknown_keywords = options.keys - known_keywords
43
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
191
44
  end
192
45
 
193
- protected
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
51
+ end
194
52
 
195
53
  def time_zone
196
54
  @time_zone ||= begin
@@ -201,198 +59,209 @@ module Groupdate
201
59
  end
202
60
 
203
61
  def week_start
204
- @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
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
205
66
  end
206
67
 
207
68
  def day_start
208
69
  @day_start ||= ((options[:day_start] || Groupdate.day_start).to_f * 3600).round
209
70
  end
210
71
 
211
- def time_range
212
- @time_range ||= begin
72
+ def range
73
+ @range ||= begin
213
74
  time_range = options[:range]
214
- if time_range.is_a?(Range) && time_range.first.is_a?(Date)
215
- # convert range of dates to range of times
216
- # use parsing instead of in_time_zone due to Rails < 4
217
- last = time_zone.parse(time_range.last.to_s)
218
- last += 1.day unless time_range.exclude_end?
219
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
220
- elsif !time_range && options[:last]
221
- if field == :quarter
222
- step = 3.months
223
- elsif 1.respond_to?(field)
224
- step = 1.send(field)
225
- else
226
- raise ArgumentError, "Cannot use last option with #{field}"
227
- end
228
- if step
229
- now = Time.now
230
- # loop instead of multiply to change start_at - see #151
231
- start_at = now
232
- (options[:last].to_i - 1).times do
233
- start_at -= step
234
- end
235
75
 
236
- time_range =
237
- if options[:current] == false
238
- round_time(start_at - step)...round_time(now)
239
- else
240
- round_time(start_at)..now
241
- end
242
- end
76
+ if time_range.is_a?(Range) && time_range.begin.nil? && time_range.end.nil?
77
+ nil
78
+ else
79
+ time_range
243
80
  end
244
- time_range
245
81
  end
246
82
  end
247
83
 
248
- def series(count, default_value, multiple_groups = false, reverse = false, series_default = true)
249
- reverse = !reverse if options[:reverse]
250
-
251
- series =
252
- case field
253
- when :day_of_week
254
- 0..6
255
- when :hour_of_day
256
- 0..23
257
- when :day_of_month
258
- 1..31
259
- when :month_of_year
260
- 1..12
261
- else
262
- time_range = self.time_range
263
- time_range =
264
- if time_range.is_a?(Range)
265
- time_range
266
- else
267
- # use first and last values
268
- sorted_keys =
269
- if multiple_groups
270
- count.keys.map { |k| k[@group_index] }.sort
271
- else
272
- count.keys.sort
273
- end
274
- sorted_keys.first..sorted_keys.last
275
- end
84
+ def series_builder
85
+ @series_builder ||=
86
+ SeriesBuilder.new(
87
+ **options,
88
+ period: period,
89
+ time_zone: time_zone,
90
+ range: range,
91
+ day_start: day_start,
92
+ week_start: week_start,
93
+ n_seconds: n_seconds
94
+ )
95
+ end
276
96
 
277
- if time_range.first
278
- series = [round_time(time_range.first)]
97
+ def time_range
98
+ series_builder.time_range
99
+ end
279
100
 
280
- if field == :quarter
281
- step = 3.months
282
- else
283
- step = 1.send(field)
284
- end
101
+ def self.validate_period(period, permit)
102
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
103
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
104
+ end
285
105
 
286
- last_step = series.last
287
- while (next_step = round_time(last_step + step)) && time_range.cover?(next_step)
288
- if next_step == last_step
289
- last_step += step
290
- next
291
- end
292
- series << next_step
293
- last_step = next_step
294
- end
106
+ class Enumerable < Magic
107
+ def group_by(enum, &_block)
108
+ group = enum.group_by do |v|
109
+ v = yield(v)
110
+ raise ArgumentError, "Not a time" unless v.respond_to?(:to_time)
111
+ series_builder.round_time(v)
112
+ end
113
+ series_builder.generate(group, default_value: [], series_default: false)
114
+ end
295
115
 
296
- series
116
+ def self.group_by(enum, period, options, &block)
117
+ Groupdate::Magic::Enumerable.new(period: period, **options).group_by(enum, &block)
118
+ end
119
+ end
120
+
121
+ class Relation < Magic
122
+ def initialize(**options)
123
+ super(**options.reject { |k, _| [:default_value, :carry_forward, :last, :current].include?(k) })
124
+ @options = options
125
+ end
126
+
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
+
132
+ multiple_groups = relation.group_values.size > 1
133
+
134
+ check_nils(result, multiple_groups, relation)
135
+ result = cast_result(result, multiple_groups)
136
+
137
+ series_builder.generate(
138
+ result,
139
+ default_value: options.key?(:default_value) ? options[:default_value] : default_value,
140
+ multiple_groups: multiple_groups,
141
+ group_index: group_index
142
+ )
143
+ end
144
+
145
+ def cast_method
146
+ @cast_method ||= begin
147
+ case period
148
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
149
+ lambda { |k| k.to_i }
150
+ when :day_of_week
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
297
162
  else
298
- []
163
+ utc = ActiveSupport::TimeZone["UTC"]
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) }
299
165
  end
300
166
  end
167
+ end
301
168
 
302
- series =
303
- if multiple_groups
304
- keys = count.keys.map { |k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
305
- series = series.to_a.reverse if reverse
306
- keys.flat_map do |k|
307
- series.map { |s| k[0...@group_index] + [s] + k[@group_index..-1] }
169
+ def cast_result(result, multiple_groups)
170
+ new_result = {}
171
+ result.each do |k, v|
172
+ if multiple_groups
173
+ k[group_index] = cast_method.call(k[group_index])
174
+ else
175
+ k = cast_method.call(k)
308
176
  end
309
- else
310
- series
177
+ new_result[k] = v
311
178
  end
179
+ new_result
180
+ end
312
181
 
313
- # reversed above if multiple groups
314
- series = series.to_a.reverse if !multiple_groups && reverse
182
+ def time_zone_support?(relation)
183
+ if relation.connection.adapter_name =~ /mysql/i
184
+ # need to call klass for Rails < 5.2
185
+ sql = relation.klass.send(:sanitize_sql_array, ["SELECT CONVERT_TZ(NOW(), '+00:00', ?)", time_zone.tzinfo.name])
186
+ !relation.connection.select_all(sql).first.values.first.nil?
187
+ else
188
+ true
189
+ end
190
+ end
315
191
 
316
- locale = options[:locale] || I18n.locale
317
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
318
- key_format =
319
- if options[:format]
320
- if options[:format].respond_to?(:call)
321
- options[:format]
192
+ def check_nils(result, multiple_groups, relation)
193
+ has_nils = multiple_groups ? (result.keys.first && result.keys.first[group_index].nil?) : result.key?(nil)
194
+ if has_nils
195
+ if time_zone_support?(relation)
196
+ raise Groupdate::Error, "Invalid query - be sure to use a date or time column"
322
197
  else
323
- sunday = time_zone.parse("2014-03-02 00:00:00")
324
- lambda do |key|
325
- case field
326
- when :hour_of_day
327
- key = sunday + key.hours + day_start.seconds
328
- when :day_of_week
329
- key = sunday + key.days
330
- when :day_of_month
331
- key = Date.new(2014, 1, key).to_time
332
- when :month_of_year
333
- key = Date.new(2014, key, 1).to_time
334
- end
335
- I18n.localize(key, format: options[:format], locale: locale)
336
- end
198
+ raise Groupdate::Error, "Database missing time zone support for #{time_zone.tzinfo.name} - see https://github.com/ankane/groupdate#for-mysql"
337
199
  end
338
- elsif [:day, :week, :month, :quarter, :year].include?(field) && use_dates
339
- lambda { |k| k.to_date }
340
- else
341
- lambda { |k| k }
342
200
  end
343
-
344
- use_series = options.key?(:series) ? options[:series] : series_default
345
- if use_series == false
346
- series = series.select { |k| count[k] }
347
201
  end
348
202
 
349
- value = 0
350
- Hash[series.map do |k|
351
- value = count[k] || (@options[:carry_forward] && value) || default_value
352
- [multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), value]
353
- end]
354
- end
203
+ def self.generate_relation(relation, field:, **options)
204
+ magic = Groupdate::Magic::Relation.new(**options)
205
+
206
+ adapter_name = relation.connection.adapter_name
207
+ adapter = Groupdate.adapters[adapter_name]
208
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
209
+
210
+ # very important
211
+ column = validate_column(field)
212
+ column = resolve_column(relation, column)
213
+
214
+ # generate ActiveRecord relation
215
+ relation =
216
+ adapter.new(
217
+ relation,
218
+ column: column,
219
+ period: magic.period,
220
+ time_zone: magic.time_zone,
221
+ time_range: magic.time_range,
222
+ week_start: magic.week_start,
223
+ day_start: magic.day_start,
224
+ n_seconds: magic.n_seconds
225
+ ).generate
226
+
227
+ # add Groupdate info
228
+ magic.group_index = relation.group_values.size - 1
229
+ (relation.groupdate_values ||= []) << magic
230
+
231
+ relation
232
+ end
355
233
 
356
- def round_time(time)
357
- time = time.to_time.in_time_zone(time_zone) - day_start.seconds
358
-
359
- time =
360
- case field
361
- when :second
362
- time.change(usec: 0)
363
- when :minute
364
- time.change(sec: 0)
365
- when :hour
366
- time.change(min: 0)
367
- when :day
368
- time.beginning_of_day
369
- when :week
370
- # same logic as MySQL group
371
- weekday = (time.wday - 1) % 7
372
- (time - ((7 - week_start + weekday) % 7).days).midnight
373
- when :month
374
- time.beginning_of_month
375
- when :quarter
376
- time.beginning_of_quarter
377
- when :year
378
- time.beginning_of_year
379
- when :hour_of_day
380
- time.hour
381
- when :day_of_week
382
- time.wday
383
- when :day_of_month
384
- time.day
385
- when :month_of_year
386
- time.month
387
- else
388
- raise Groupdate::Error, "Invalid field"
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
389
246
  end
390
247
 
391
- time.is_a?(Time) ? time + day_start.seconds : time
392
- end
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
393
257
 
394
- def activerecord42?
395
- ActiveRecord::VERSION::STRING.starts_with?("4.2.")
258
+ # allow any options to keep flexible for future
259
+ def self.process_result(relation, result, **options)
260
+ relation.groupdate_values.reverse.each do |gv|
261
+ result = gv.perform(relation, result, default_value: options[:default_value])
262
+ end
263
+ result
264
+ end
396
265
  end
397
266
  end
398
267
  end
@@ -0,0 +1,18 @@
1
+ module Groupdate
2
+ module QueryMethods
3
+ Groupdate::PERIODS.each do |period|
4
+ define_method :"group_by_#{period}" do |field, **options|
5
+ Groupdate::Magic::Relation.generate_relation(self,
6
+ period: period,
7
+ field: field,
8
+ **options
9
+ )
10
+ end
11
+ end
12
+
13
+ def group_by_period(period, field, permit: nil, **options)
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
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
+ # prevent calculate from being called twice
13
+ return super if ActiveRecord::VERSION::STRING.to_f >= 6.1 && has_include?(args[1])
14
+
15
+ default_value = [:count, :sum].include?(args[0]) ? 0 : nil
16
+ Groupdate.process_result(self, super, default_value: default_value)
17
+ end
18
+ end
19
+ end