groupdate 3.2.0 → 6.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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