groupdate 4.1.2 → 5.2.0

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.
@@ -1,31 +1,25 @@
1
1
  module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
- define_method :"group_by_#{period}" do |*args, &block|
3
+ define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- Groupdate::Magic::Enumerable.group_by(self, period, args[0] || {}, &block)
5
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
+ Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
6
7
  elsif respond_to?(:scoping)
7
- scoping { @klass.send(:"group_by_#{period}", *args, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
8
9
  else
9
10
  raise ArgumentError, "no block given"
10
11
  end
11
12
  end
12
13
  end
13
14
 
14
- def group_by_period(*args, &block)
15
+ def group_by_period(period, *args, **options, &block)
15
16
  if block || !respond_to?(:scoping)
16
- period = args[0]
17
- options = args[1] || {}
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
18
 
19
- options = options.dup
20
- # to_sym is unsafe on user input, so convert to strings
21
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
22
- if permitted_periods.include?(period.to_s)
23
- send("group_by_#{period}", options, &block)
24
- else
25
- raise ArgumentError, "Unpermitted period"
26
- end
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
27
21
  else
28
- scoping { @klass.send(:group_by_period, *args, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
29
23
  end
30
24
  end
31
25
  end
@@ -2,18 +2,52 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
- attr_accessor :period, :options, :group_index
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
7
+ attr_accessor :period, :options, :group_index, :n_seconds
6
8
 
7
9
  def initialize(period:, **options)
8
10
  @period = period
9
11
  @options = options
10
12
 
11
- unknown_keywords = options.keys - [:day_start, :time_zone, :dates, :series, :week_start, :format, :locale, :range, :reverse]
13
+ validate_keywords
14
+ validate_arguments
15
+
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
21
+ end
22
+ end
23
+
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :dates, :series, :format, :locale, :range, :reverse]
26
+
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
29
+ end
30
+
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
36
+ end
37
+
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
40
+ end
41
+
42
+ unknown_keywords = options.keys - known_keywords
12
43
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
44
+ end
13
45
 
14
- raise Groupdate::Error, "Unrecognized time zone" unless time_zone
15
- raise Groupdate::Error, "Unrecognized :week_start option" if period == :week && !week_start
16
- raise Groupdate::Error, "Cannot use endless range for :range option" if options[:range].is_a?(Range) && !options[:range].end
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
17
51
  end
18
52
 
19
53
  def time_zone
@@ -25,7 +59,10 @@ module Groupdate
25
59
  end
26
60
 
27
61
  def week_start
28
- @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
29
66
  end
30
67
 
31
68
  def day_start
@@ -39,7 +76,8 @@ module Groupdate
39
76
  period: period,
40
77
  time_zone: time_zone,
41
78
  day_start: day_start,
42
- week_start: week_start
79
+ week_start: week_start,
80
+ n_seconds: n_seconds
43
81
  )
44
82
  end
45
83
 
@@ -47,6 +85,11 @@ module Groupdate
47
85
  series_builder.time_range
48
86
  end
49
87
 
88
+ def self.validate_period(period, permit)
89
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
90
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
91
+ end
92
+
50
93
  class Enumerable < Magic
51
94
  def group_by(enum, &_block)
52
95
  group = enum.group_by do |v|
@@ -85,10 +128,10 @@ module Groupdate
85
128
  def cast_method
86
129
  @cast_method ||= begin
87
130
  case period
131
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
132
+ lambda { |k| k.to_i }
88
133
  when :day_of_week
89
134
  lambda { |k| (k.to_i - 1 - week_start) % 7 }
90
- when :hour_of_day, :day_of_month, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
92
135
  else
93
136
  utc = ActiveSupport::TimeZone["UTC"]
94
137
  lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
@@ -142,7 +185,8 @@ module Groupdate
142
185
  time_zone: magic.time_zone,
143
186
  time_range: magic.time_range,
144
187
  week_start: magic.week_start,
145
- day_start: magic.day_start
188
+ day_start: magic.day_start,
189
+ n_seconds: magic.n_seconds
146
190
  ).generate
147
191
 
148
192
  # add Groupdate info
@@ -1,25 +1,18 @@
1
1
  module Groupdate
2
2
  module QueryMethods
3
3
  Groupdate::PERIODS.each do |period|
4
- define_method :"group_by_#{period}" do |field, time_zone = nil, range = nil, **options|
4
+ define_method :"group_by_#{period}" do |field, **options|
5
5
  Groupdate::Magic::Relation.generate_relation(self,
6
6
  period: period,
7
7
  field: field,
8
- time_zone: time_zone,
9
- range: range,
10
8
  **options
11
9
  )
12
10
  end
13
11
  end
14
12
 
15
13
  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
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
23
16
  end
24
17
  end
25
18
  end
@@ -1,8 +1,11 @@
1
1
  module Groupdate
2
2
  class RelationBuilder
3
- attr_reader :period, :column, :day_start, :week_start
3
+ attr_reader :period, :column, :day_start, :week_start, :n_seconds
4
+
5
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:)
6
+ # very important
7
+ validate_column(column)
4
8
 
5
- def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:)
6
9
  @relation = relation
7
10
  @column = resolve_column(relation, column)
8
11
  @period = period
@@ -10,6 +13,7 @@ module Groupdate
10
13
  @time_range = time_range
11
14
  @week_start = week_start
12
15
  @day_start = day_start
16
+ @n_seconds = n_seconds
13
17
 
14
18
  if relation.default_timezone == :local
15
19
  raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
@@ -27,22 +31,28 @@ module Groupdate
27
31
  adapter_name = @relation.connection.adapter_name
28
32
  query =
29
33
  case adapter_name
30
- when "MySQL", "Mysql2", "Mysql2Spatial", 'Mysql2Rgeo'
34
+ when "Mysql2", "Mysql2Spatial", "Mysql2Rgeo"
35
+ day_start_column = "CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL ? second"
36
+
31
37
  case period
32
- when :day_of_week
33
- ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1", time_zone]
34
- when :hour_of_day
35
- ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
36
38
  when :minute_of_hour
37
- ["(EXTRACT(MINUTE from CONVERT_TZ(#{column}, '+00:00', ?)))", time_zone]
39
+ ["MINUTE(#{day_start_column})", time_zone, day_start]
40
+ when :hour_of_day
41
+ ["HOUR(#{day_start_column})", time_zone, day_start]
42
+ when :day_of_week
43
+ ["DAYOFWEEK(#{day_start_column}) - 1", time_zone, day_start]
38
44
  when :day_of_month
39
- ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
45
+ ["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
46
+ when :day_of_year
47
+ ["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
40
48
  when :month_of_year
41
- ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
49
+ ["MONTH(#{day_start_column})", time_zone, day_start]
42
50
  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]
51
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column} - INTERVAL ((? + DAYOFWEEK(#{day_start_column})) % 7) DAY, '%Y-%m-%d 00:00:00') + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, 12 - week_start, time_zone, day_start, day_start, time_zone]
44
52
  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]
53
+ ["CONVERT_TZ(DATE_FORMAT(DATE(CONCAT(YEAR(#{day_start_column}), '-', LPAD(1 + 3 * (QUARTER(#{day_start_column}) - 1), 2, '00'), '-01')), '%Y-%m-%d %H:%i:%S') + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, time_zone, day_start, day_start, time_zone]
54
+ when :custom
55
+ ["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
46
56
  else
47
57
  format =
48
58
  case period
@@ -60,43 +70,58 @@ module Groupdate
60
70
  "%Y-01-01 00:00:00"
61
71
  end
62
72
 
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]
73
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
64
74
  end
65
75
  when "PostgreSQL", "PostGIS"
76
+ day_start_column = "#{column}::timestamptz AT TIME ZONE ? - INTERVAL ?"
77
+ day_start_interval = "#{day_start} second"
78
+
66
79
  case period
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
80
  when :minute_of_hour
72
- ["EXTRACT(MINUTE from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
81
+ ["EXTRACT(MINUTE FROM #{day_start_column})::integer", time_zone, day_start_interval]
82
+ when :hour_of_day
83
+ ["EXTRACT(HOUR FROM #{day_start_column})::integer", time_zone, day_start_interval]
84
+ when :day_of_week
85
+ ["EXTRACT(DOW FROM #{day_start_column})::integer", time_zone, day_start_interval]
73
86
  when :day_of_month
74
- ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
87
+ ["EXTRACT(DAY FROM #{day_start_column})::integer", time_zone, day_start_interval]
88
+ when :day_of_year
89
+ ["EXTRACT(DOY FROM #{day_start_column})::integer", time_zone, day_start_interval]
75
90
  when :month_of_year
76
- ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
77
- when :week # start on Sunday, not PostgreSQL default Monday
78
- ["(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]
91
+ ["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
92
+ when :week
93
+ ["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?) AT TIME ZONE ?", time_zone, day_start_interval, 13 - week_start, time_zone, day_start_interval, day_start_interval, time_zone]
94
+ when :custom
95
+ ["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
79
96
  else
80
- ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
97
+ if day_start == 0
98
+ # prettier
99
+ ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
100
+ else
101
+ ["(DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?) AT TIME ZONE ?", period, time_zone, day_start_interval, day_start_interval, time_zone]
102
+ end
81
103
  end
82
104
  when "SQLite"
83
105
  raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
84
106
  raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
85
- raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
86
107
 
87
108
  if period == :week
88
- ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
109
+ ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
110
+ elsif period == :custom
111
+ ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
89
112
  else
90
113
  format =
91
114
  case period
92
- when :hour_of_day
93
- "%H"
94
115
  when :minute_of_hour
95
116
  "%M"
117
+ when :hour_of_day
118
+ "%H"
96
119
  when :day_of_week
97
120
  "%w"
98
121
  when :day_of_month
99
122
  "%d"
123
+ when :day_of_year
124
+ "%j"
100
125
  when :month_of_year
101
126
  "%m"
102
127
  when :second
@@ -115,37 +140,40 @@ module Groupdate
115
140
  "%Y-01-01 00:00:00 UTC"
116
141
  end
117
142
 
118
- ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
143
+ ["strftime(?, #{column})", format]
119
144
  end
120
145
  when "Redshift"
146
+ day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
147
+ day_start_interval = "#{day_start} second"
148
+
121
149
  case period
122
- when :day_of_week
123
- ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
124
- when :hour_of_day
125
- ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
126
150
  when :minute_of_hour
127
- ["EXTRACT(MINUTE from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
151
+ ["EXTRACT(MINUTE from #{day_start_column})::integer", time_zone, day_start_interval]
152
+ when :hour_of_day
153
+ ["EXTRACT(HOUR from #{day_start_column})::integer", time_zone, day_start_interval]
154
+ when :day_of_week
155
+ ["EXTRACT(DOW from #{day_start_column})::integer", time_zone, day_start_interval]
128
156
  when :day_of_month
129
- ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
157
+ ["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
158
+ when :day_of_year
159
+ ["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
130
160
  when :month_of_year
131
- ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
161
+ ["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
132
162
  when :week # start on Sunday, not Redshift default Monday
133
163
  # Redshift does not return timezone information; it
134
164
  # always says it is in UTC time, so we must convert
135
165
  # back to UTC to play properly with the rest of Groupdate.
136
- #
137
- ["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]
166
+ week_start_interval = "#{week_start} day"
167
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC('week', #{day_start_column} - INTERVAL ?) + INTERVAL ? + INTERVAL ?)::timestamp", time_zone, time_zone, day_start_interval, week_start_interval, week_start_interval, day_start_interval]
168
+ when :custom
169
+ raise Groupdate::Error, "Not implemented yet"
138
170
  else
139
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
171
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?)::timestamp", time_zone, period, time_zone, day_start_interval, day_start_interval]
140
172
  end
141
173
  else
142
174
  raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
143
175
  end
144
176
 
145
- if adapter_name == "MySQL" && period == :week
146
- query[0] = "CAST(#{query[0]} AS DATETIME)"
147
- end
148
-
149
177
  clause = @relation.send(:sanitize_sql_array, query)
150
178
 
151
179
  # cleaner queries in logs
@@ -158,22 +186,35 @@ module Groupdate
158
186
  end
159
187
 
160
188
  def clean_group_clause_mysql(clause)
161
- clause = clause.gsub("DATE_SUB(#{column}, INTERVAL 0 second)", "#{column}")
162
- if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
163
- clause = clause[9..-21]
164
- end
165
- clause
189
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
166
190
  end
167
191
 
168
192
  def where_clause
169
193
  if @time_range.is_a?(Range)
170
- op = @time_range.exclude_end? ? "<" : "<="
171
- ["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
194
+ if @time_range.end
195
+ op = @time_range.exclude_end? ? "<" : "<="
196
+ if @time_range.begin
197
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
198
+ else
199
+ ["#{column} #{op} ?", @time_range.last]
200
+ end
201
+ else
202
+ ["#{column} >= ?", @time_range.first]
203
+ end
172
204
  else
173
205
  ["#{column} IS NOT NULL"]
174
206
  end
175
207
  end
176
208
 
209
+ # basic version of Active Record disallow_raw_sql!
210
+ # symbol = column (safe), Arel node = SQL (safe), other = untrusted
211
+ def validate_column(column)
212
+ # matches table.column and column
213
+ unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral) || /\A\w+(\.\w+)?\z/i.match(column.to_s)
214
+ warn "[groupdate] Non-attribute argument: #{column}. Use Arel.sql() for known-safe values. This will raise an error in Groupdate 6"
215
+ end
216
+ end
217
+
177
218
  # resolves eagerly
178
219
  # need to convert both where_clause (easy)
179
220
  # and group_clause (not easy) if want to avoid this
@@ -1,29 +1,64 @@
1
1
  module Groupdate
2
2
  class SeriesBuilder
3
- attr_reader :period, :time_zone, :day_start, :week_start, :options
3
+ attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
4
4
 
5
5
  CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
6
 
7
- def initialize(period:, time_zone:, day_start:, week_start:, **options)
7
+ def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
8
  @period = period
9
9
  @time_zone = time_zone
10
10
  @week_start = week_start
11
11
  @day_start = day_start
12
+ @n_seconds = n_seconds
12
13
  @options = options
13
- @round_time = {}
14
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
14
15
  end
15
16
 
16
17
  def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
18
  series = generate_series(data, multiple_groups, group_index)
18
19
  series = handle_multiple(data, series, multiple_groups, group_index)
19
20
 
21
+ verified_data = {}
22
+ series.each do |k|
23
+ verified_data[k] = data.delete(k)
24
+ end
25
+
26
+ # this is a fun one
27
+ # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
28
+ # Other databases and Active Support return the 1st hour (as expected)
29
+ # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
30
+ # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
31
+ # Ruby not good: Time.parse("2013-11-03 01:00:00")
32
+ # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
33
+ # we need to account for this here
34
+ if series_default && CHECK_PERIODS.include?(period)
35
+ data.each do |k, v|
36
+ key = multiple_groups ? k[group_index] : k
37
+ # TODO only do this for PostgreSQL
38
+ # this may mask some inconsistent time zone errors
39
+ # but not sure there's a better approach
40
+ if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
41
+ key -= 1.hour
42
+ if multiple_groups
43
+ k[group_index] = key
44
+ else
45
+ k = key
46
+ end
47
+ verified_data[k] = v
48
+ elsif key != round_time(key)
49
+ # only need to show what database returned since it will cast in Ruby time zone
50
+ raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
51
+ end
52
+ end
53
+ end
54
+
20
55
  unless entire_series?(series_default)
21
- series = series.select { |k| data[k] }
56
+ series = series.select { |k| verified_data[k] }
22
57
  end
23
58
 
24
59
  value = 0
25
60
  result = Hash[series.map do |k|
26
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
61
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
27
62
  key =
28
63
  if multiple_groups
29
64
  k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
@@ -34,20 +69,21 @@ module Groupdate
34
69
  [key, value]
35
70
  end]
36
71
 
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
72
  result
44
73
  end
45
74
 
46
75
  def round_time(time)
76
+ if period == :custom
77
+ return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
78
+ end
79
+
47
80
  time = time.to_time.in_time_zone(time_zone)
48
81
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
82
+ if day_start != 0
83
+ # apply day_start to a time object that's not affected by DST
84
+ time = change_zone.call(time, utc)
85
+ time -= day_start.seconds
86
+ end
51
87
 
52
88
  time =
53
89
  case period
@@ -60,9 +96,7 @@ module Groupdate
60
96
  when :day
61
97
  time.beginning_of_day
62
98
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
99
+ time.beginning_of_week(@week_start_key)
66
100
  when :month
67
101
  time.beginning_of_month
68
102
  when :quarter
@@ -74,33 +108,54 @@ module Groupdate
74
108
  when :minute_of_hour
75
109
  time.min
76
110
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
111
+ time.days_to_week_start(@week_start_key)
78
112
  when :day_of_month
79
113
  time.day
80
114
  when :month_of_year
81
115
  time.month
116
+ when :day_of_year
117
+ time.yday
82
118
  else
83
119
  raise Groupdate::Error, "Invalid period"
84
120
  end
85
121
 
86
- # only if day_start != 0 for performance
87
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
122
+ if day_start != 0 && time.is_a?(Time)
123
+ time += day_start.seconds
124
+ time = change_zone.call(time, time_zone)
125
+ end
88
126
 
89
127
  time
90
128
  end
91
129
 
130
+ def change_zone
131
+ @change_zone ||= begin
132
+ if ActiveSupport::VERSION::STRING >= "5.2"
133
+ ->(time, zone) { time.change(zone: zone) }
134
+ else
135
+ # TODO make more efficient
136
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
137
+ end
138
+ end
139
+ end
140
+
92
141
  def time_range
93
142
  @time_range ||= begin
94
143
  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)
144
+ # entire range must be Date if begin or end is Date
145
+ if time_range.is_a?(Range) && (time_range.begin.is_a?(Date) || time_range.end.is_a?(Date))
146
+ if time_range.begin
147
+ start = time_zone.parse(time_range.first.to_s)
148
+ end
149
+ if time_range.end
150
+ last = time_zone.parse(time_range.last.to_s)
151
+ last += 1.day unless time_range.exclude_end?
152
+ end
153
+ time_range = Range.new(start, last, true)
101
154
  elsif !time_range && options[:last]
102
155
  if period == :quarter
103
156
  step = 3.months
157
+ elsif period == :custom
158
+ step = n_seconds
104
159
  elsif 1.respond_to?(period)
105
160
  step = 1.send(period)
106
161
  else
@@ -117,7 +172,8 @@ module Groupdate
117
172
  if options[:current] == false
118
173
  round_time(start_at - step)...round_time(now)
119
174
  else
120
- round_time(start_at)..now
175
+ # extend to end of current period
176
+ round_time(start_at)...(round_time(now) + step)
121
177
  end
122
178
  end
123
179
  end
@@ -141,12 +197,14 @@ module Groupdate
141
197
  0..59
142
198
  when :day_of_month
143
199
  1..31
200
+ when :day_of_year
201
+ 1..366
144
202
  when :month_of_year
145
203
  1..12
146
204
  else
147
205
  time_range = self.time_range
148
206
  time_range =
149
- if time_range.is_a?(Range)
207
+ if time_range.is_a?(Range) && time_range.begin && time_range.end
150
208
  time_range
151
209
  else
152
210
  # use first and last values
@@ -157,26 +215,41 @@ module Groupdate
157
215
  data.keys.sort
158
216
  end
159
217
 
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)
218
+ if time_range.is_a?(Range)
219
+ if sorted_keys.any?
220
+ if time_range.begin
221
+ time_range.begin..sorted_keys.last
222
+ else
223
+ Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
224
+ end
225
+ else
226
+ nil..nil
227
+ end
228
+ else
229
+ tr = sorted_keys.first..sorted_keys.last
230
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
231
+ tr = tr.first...round_time(now)
232
+ end
233
+ tr
163
234
  end
164
- tr
165
235
  end
166
236
 
167
- if time_range.first
168
- series = [round_time(time_range.first)]
237
+ if time_range.begin
238
+ series = [round_time(time_range.begin)]
169
239
 
170
240
  if period == :quarter
171
241
  step = 3.months
242
+ elsif period == :custom
243
+ step = n_seconds
172
244
  else
173
245
  step = 1.send(period)
174
246
  end
175
247
 
176
248
  last_step = series.last
249
+ day_start_hour = day_start / 3600
177
250
  loop do
178
251
  next_step = last_step + step
179
- next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
252
+ next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
180
253
  break unless time_range.cover?(next_step)
181
254
 
182
255
  if next_step == last_step
@@ -195,34 +268,36 @@ module Groupdate
195
268
  end
196
269
 
197
270
  def key_format
198
- locale = options[:locale] || I18n.locale
199
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
271
+ @key_format ||= begin
272
+ locale = options[:locale] || I18n.locale
273
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
200
274
 
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
275
+ if options[:format]
276
+ if options[:format].respond_to?(:call)
277
+ options[:format]
278
+ else
279
+ sunday = time_zone.parse("2014-03-02 00:00:00")
280
+ lambda do |key|
281
+ case period
282
+ when :hour_of_day
283
+ key = sunday + key.hours + day_start.seconds
284
+ when :minute_of_hour
285
+ key = sunday + key.minutes + day_start.seconds
286
+ when :day_of_week
287
+ key = sunday + key.days + (week_start + 1).days
288
+ when :day_of_month
289
+ key = Date.new(2014, 1, key).to_time
290
+ when :month_of_year
291
+ key = Date.new(2014, key, 1).to_time
292
+ end
293
+ I18n.localize(key, format: options[:format], locale: locale)
218
294
  end
219
- I18n.localize(key, format: options[:format], locale: locale)
220
295
  end
296
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
297
+ lambda { |k| k.to_date }
298
+ else
299
+ lambda { |k| k }
221
300
  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
301
  end
227
302
  end
228
303
 
@@ -242,23 +317,12 @@ module Groupdate
242
317
  end
243
318
  end
244
319
 
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}"
256
- end
257
- end
258
- end
259
-
260
320
  def entire_series?(series_default)
261
321
  options.key?(:series) ? options[:series] : series_default
262
322
  end
323
+
324
+ def utc
325
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
326
+ end
263
327
  end
264
328
  end