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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +146 -27
- data/CONTRIBUTING.md +75 -0
- data/LICENSE.txt +1 -1
- data/README.md +67 -44
- data/lib/groupdate/active_record.rb +4 -51
- data/lib/groupdate/adapters/base_adapter.rb +51 -0
- data/lib/groupdate/adapters/mysql_adapter.rb +63 -0
- data/lib/groupdate/adapters/postgresql_adapter.rb +46 -0
- data/lib/groupdate/adapters/sqlite_adapter.rb +51 -0
- data/lib/groupdate/enumerable.rb +9 -14
- data/lib/groupdate/magic.rb +202 -333
- data/lib/groupdate/query_methods.rb +18 -0
- data/lib/groupdate/relation.rb +19 -0
- data/lib/groupdate/series_builder.rb +304 -0
- data/lib/groupdate/version.rb +1 -1
- data/lib/groupdate.rb +37 -7
- metadata +20 -145
- data/.gitignore +0 -19
- data/.travis.yml +0 -25
- data/Gemfile +0 -6
- data/Rakefile +0 -24
- data/groupdate.gemspec +0 -37
- data/lib/groupdate/calculations.rb +0 -26
- data/lib/groupdate/order_hack.rb +0 -11
- data/lib/groupdate/scopes.rb +0 -24
- data/lib/groupdate/series.rb +0 -30
- data/test/enumerable_test.rb +0 -60
- data/test/gemfiles/activerecord31.gemfile +0 -6
- data/test/gemfiles/activerecord32.gemfile +0 -6
- data/test/gemfiles/activerecord40.gemfile +0 -6
- data/test/gemfiles/activerecord41.gemfile +0 -6
- data/test/gemfiles/activerecord42.gemfile +0 -6
- data/test/gemfiles/redshift.gemfile +0 -7
- data/test/mysql_test.rb +0 -15
- data/test/postgresql_test.rb +0 -15
- data/test/redshift_test.rb +0 -18
- data/test/sqlite_test.rb +0 -29
- data/test/test_helper.rb +0 -1207
data/lib/groupdate/magic.rb
CHANGED
@@ -2,195 +2,53 @@ require "i18n"
|
|
2
2
|
|
3
3
|
module Groupdate
|
4
4
|
class Magic
|
5
|
-
|
5
|
+
DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
|
6
6
|
|
7
|
-
|
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
|
-
|
14
|
-
|
9
|
+
def initialize(period:, **options)
|
10
|
+
@period = period
|
11
|
+
@options = options
|
15
12
|
|
16
|
-
|
17
|
-
|
18
|
-
series(group, [], false, false, false)
|
19
|
-
end
|
13
|
+
validate_keywords
|
14
|
+
validate_arguments
|
20
15
|
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
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
|
140
|
-
|
27
|
+
if %i[week day_of_week].include?(period)
|
28
|
+
known_keywords << :week_start
|
141
29
|
end
|
142
30
|
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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 ||=
|
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
|
212
|
-
@
|
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
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
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
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
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
|
-
|
278
|
-
|
97
|
+
def time_range
|
98
|
+
series_builder.time_range
|
99
|
+
end
|
279
100
|
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
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
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
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
|
-
|
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
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
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
|
-
|
310
|
-
series
|
177
|
+
new_result[k] = v
|
311
178
|
end
|
179
|
+
new_result
|
180
|
+
end
|
312
181
|
|
313
|
-
|
314
|
-
|
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
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
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
|
-
|
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
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
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
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
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
|
-
|
392
|
-
|
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
|
-
|
395
|
-
|
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
|