groupdate 4.3.0 → 5.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c661470fa1d3e85d4cff490be69845b175f5c095956ae674bda74e25e8713d1e
4
- data.tar.gz: d2f4a2adb3dec4d73fdfc36302514c7c9c051766bb2703057b4074e3f480223c
3
+ metadata.gz: fee4526fc30babd6bcffe4121102670b945ac37e5017c47eff03e406a83bf029
4
+ data.tar.gz: ce960c7d5c3166f212f93c358ffae736b4306bdedfef6a6db4fe137aea628ade
5
5
  SHA512:
6
- metadata.gz: f7e3309e4f687cb88839c23d07f6ad9dce57ee3f8f18c335d695d79fe4c0d7fe4e236d1362071180ba07daf3eb6edd62ace05f4d15e0f4392c2dc48e81139c17
7
- data.tar.gz: 2b1682b63429bb482b01cb8f50a5bd0ab03a0677b9a806244c7d26054579611b1678f056c713d23278cd5cd3e8616def5c77d0897caccda7c5a6d297e4eaa863
6
+ metadata.gz: fbb0de4e049d1765067cc4c0a175be1e1808b2c56016e54566d00f9b87234cc90fa7ac61d3fbf7683597369d67e2be38dcd08dad9ce20d734224f102d00456be
7
+ data.tar.gz: 465694dd5bfb028c1f7f3cd3c9d5fc9ff3dab28de64f394bf765c5f71fdfed67a797ebaf9f24a7a8d3a808e684b296872f58cd71bae088676e3e767e089ab121
@@ -1,3 +1,16 @@
1
+ ## 5.0.0 (2020-02-18)
2
+
3
+ - Added support for `week_start` for SQLite
4
+ - Added support for full weekday names
5
+ - Made `day_start` behavior consistent between Active Record and enumerable
6
+ - Made `last` option extend to end of current period
7
+ - Raise error when `day_start` and `week_start` passed to unsupported methods
8
+ - The `day_start` option no longer applies to shorter periods
9
+ - Fixed `inconsistent time zone info` errors around DST with MySQL and PostgreSQL
10
+ - Improved performance of `format` option
11
+ - Removed deprecated positional arguments for time zone and range
12
+ - Dropped support for `mysql` gem (last release was 2013)
13
+
1
14
  ## 4.3.0 (2019-12-26)
2
15
 
3
16
  - Fixed error with empty results in Ruby 2.7
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2019 Andrew Kane
1
+ Copyright (c) 2013-2020 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -32,9 +32,9 @@ For MySQL and SQLite, also follow [these instructions](#additional-instructions)
32
32
  ```ruby
33
33
  User.group_by_day(:created_at).count
34
34
  # {
35
- # Sat, 28 May 2016 => 50,
36
- # Sun, 29 May 2016 => 100,
37
- # Mon, 30 May 2016 => 34
35
+ # Sat, 24 May 2020 => 50,
36
+ # Sun, 25 May 2020 => 100,
37
+ # Mon, 26 May 2020 => 34
38
38
  # }
39
39
  ```
40
40
 
@@ -53,6 +53,7 @@ You can group by:
53
53
 
54
54
  and
55
55
 
56
+ - minute_of_hour
56
57
  - hour_of_day
57
58
  - day_of_week (Sunday = 0, Monday = 1, etc)
58
59
  - day_of_month
@@ -74,9 +75,9 @@ or
74
75
  ```ruby
75
76
  User.group_by_week(:created_at, time_zone: "Pacific Time (US & Canada)").count
76
77
  # {
77
- # Sun, 06 Mar 2016 => 70,
78
- # Sun, 13 Mar 2016 => 54,
79
- # Sun, 20 Mar 2016 => 80
78
+ # Sun, 08 Mar 2020 => 70,
79
+ # Sun, 15 Mar 2020 => 54,
80
+ # Sun, 22 Mar 2020 => 80
80
81
  # }
81
82
  ```
82
83
 
@@ -87,13 +88,13 @@ Time zone objects also work. To see a list of available time zones in Rails, run
87
88
  Weeks start on Sunday by default. Change this with:
88
89
 
89
90
  ```ruby
90
- Groupdate.week_start = :mon # first three letters of day
91
+ Groupdate.week_start = :monday
91
92
  ```
92
93
 
93
94
  or
94
95
 
95
96
  ```ruby
96
- User.group_by_week(:created_at, week_start: :mon).count
97
+ User.group_by_week(:created_at, week_start: :monday).count
97
98
  ```
98
99
 
99
100
  ### Day Start
@@ -147,8 +148,8 @@ To get keys in a different format, use:
147
148
  ```ruby
148
149
  User.group_by_month(:created_at, format: "%b %Y").count
149
150
  # {
150
- # "Jan 2015" => 10
151
- # "Feb 2015" => 12
151
+ # "Jan 2020" => 10
152
+ # "Feb 2020" => 12
152
153
  # }
153
154
  ```
154
155
 
@@ -263,7 +264,7 @@ It should return the time instead of `NULL`.
263
264
  Groupdate has limited support for SQLite.
264
265
 
265
266
  - No time zone support
266
- - No `day_start` or `week_start` options
267
+ - No `day_start` option
267
268
  - No `group_by_quarter` method
268
269
 
269
270
  If your application’s time zone is set to something other than `Etc/UTC` (the default), create an initializer with:
@@ -274,29 +275,13 @@ Groupdate.time_zone = false
274
275
 
275
276
  ## Upgrading
276
277
 
277
- ### 4.0
278
+ ### 5.0
278
279
 
279
- Groupdate 4.0 brings a number of improvements. Here are a few to be aware of:
280
+ Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
280
281
 
281
- - `group_by` methods return an `ActiveRecord::Relation` instead of a `Groupdate::Series`
282
- - Invalid options now throw an `ArgumentError`
283
- - `week_start` now affects `day_of_week`
284
- - Custom calculation methods are supported by default
285
-
286
- ### 3.0
287
-
288
- Groupdate 3.0 brings a number of improvements. Here are a few to be aware of:
289
-
290
- - `Date` objects are now returned for day, week, month, quarter, and year by default. Use `dates: false` for the previous behavior, or change this globally with `Groupdate.dates = false`.
291
- - Array and hash methods no longer return the entire series by default. Use `series: true` for the previous behavior.
292
- - The `series: false` option now returns the correct type and order, and plays nicely with other options.
293
-
294
- ### 2.0
295
-
296
- Groupdate 2.0 brings a number of improvements. Here are two things to be aware of:
297
-
298
- - the entire series is returned by default
299
- - `ActiveSupport::TimeWithZone` keys are now returned for every database adapter - adapters previously returned `Time` or `String` keys
282
+ - The `week_start` option is now supported for SQLite
283
+ - The `day_start` option is now consistent between Active Record and enumerable
284
+ - Deprecated positional arguments for time zone and range have been removed
300
285
 
301
286
  ## History
302
287
 
@@ -1,9 +1,12 @@
1
+ # dependencies
1
2
  require "active_support/core_ext/module/attribute_accessors"
2
3
  require "active_support/time"
3
- require "groupdate/version"
4
+
5
+ # modules
6
+ require "groupdate/magic"
4
7
  require "groupdate/relation_builder"
5
8
  require "groupdate/series_builder"
6
- require "groupdate/magic"
9
+ require "groupdate/version"
7
10
 
8
11
  module Groupdate
9
12
  class Error < RuntimeError; end
@@ -12,7 +15,7 @@ module Groupdate
12
15
  METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
13
16
 
14
17
  mattr_accessor :week_start, :day_start, :time_zone, :dates
15
- self.week_start = :sun
18
+ self.week_start = :sunday
16
19
  self.day_start = 0
17
20
  self.dates = true
18
21
 
@@ -2,11 +2,10 @@ module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
3
  define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- # TODO throw error in Groupdate 5
6
- warn "[groupdate] positional arguments are deprecated" if args.any?
7
- Groupdate::Magic::Enumerable.group_by(self, period, (args[0] || {}).merge(options), &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)
8
7
  elsif respond_to?(:scoping)
9
- scoping { @klass.send(:"group_by_#{period}", *args, **options, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
10
9
  else
11
10
  raise ArgumentError, "no block given"
12
11
  end
@@ -15,18 +14,12 @@ module Enumerable
15
14
 
16
15
  def group_by_period(period, *args, **options, &block)
17
16
  if block || !respond_to?(:scoping)
18
- # TODO throw error in Groupdate 5
19
- warn "[groupdate] positional arguments are deprecated" if args.any?
20
- options = (args[0] || {}).merge(options)
21
- # to_sym is unsafe on user input, so convert to strings
22
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
23
- if permitted_periods.include?(period.to_s)
24
- send("group_by_#{period}", **options, &block)
25
- else
26
- raise ArgumentError, "Unpermitted period"
27
- end
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
+
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
28
21
  else
29
- scoping { @klass.send(:group_by_period, period, *args, **options, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
30
23
  end
31
24
  end
32
25
  end
@@ -2,18 +2,42 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
5
7
  attr_accessor :period, :options, :group_index
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
+ end
16
+
17
+ def validate_keywords
18
+ known_keywords = [:time_zone, :dates, :series, :format, :locale, :range, :reverse]
19
+
20
+ if %i[week day_of_week].include?(period)
21
+ known_keywords << :week_start
22
+ end
23
+
24
+ if %i[day week month quarter year day_of_week hour_of_day day_of_month day_of_year month_of_year].include?(period)
25
+ known_keywords << :day_start
26
+ else
27
+ # prevent Groupdate.day_start from applying
28
+ @day_start = 0
29
+ end
30
+
31
+ unknown_keywords = options.keys - known_keywords
12
32
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
33
+ end
13
34
 
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
35
+ def validate_arguments
36
+ # TODO better messages
37
+ raise ArgumentError, "Unrecognized time zone" unless time_zone
38
+ raise ArgumentError, "Unrecognized :week_start option" unless week_start
39
+ raise ArgumentError, "Cannot use endless range for :range option" if options[:range].is_a?(Range) && !options[:range].end
40
+ raise ArgumentError, ":day_start must be between 0 and 24" if (day_start / 3600) < 0 || (day_start / 3600) >= 24
17
41
  end
18
42
 
19
43
  def time_zone
@@ -25,7 +49,10 @@ module Groupdate
25
49
  end
26
50
 
27
51
  def week_start
28
- @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
52
+ @week_start ||= begin
53
+ v = (options[:week_start] || Groupdate.week_start).to_sym
54
+ DAYS.index(v) || [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index(v)
55
+ end
29
56
  end
30
57
 
31
58
  def day_start
@@ -47,6 +74,11 @@ module Groupdate
47
74
  series_builder.time_range
48
75
  end
49
76
 
77
+ def self.validate_period(period, permit)
78
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
79
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
80
+ end
81
+
50
82
  class Enumerable < Magic
51
83
  def group_by(enum, &_block)
52
84
  group = enum.group_by do |v|
@@ -85,10 +117,10 @@ module Groupdate
85
117
  def cast_method
86
118
  @cast_method ||= begin
87
119
  case period
120
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
121
+ lambda { |k| k.to_i }
88
122
  when :day_of_week
89
123
  lambda { |k| (k.to_i - 1 - week_start) % 7 }
90
- when :hour_of_day, :day_of_month, :day_of_year, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
92
124
  else
93
125
  utc = ActiveSupport::TimeZone["UTC"]
94
126
  lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
@@ -1,27 +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|
5
- warn "[groupdate] positional arguments for time zone and range are deprecated" if time_zone || range
6
-
4
+ define_method :"group_by_#{period}" do |field, **options|
7
5
  Groupdate::Magic::Relation.generate_relation(self,
8
6
  period: period,
9
7
  field: field,
10
- time_zone: time_zone,
11
- range: range,
12
8
  **options
13
9
  )
14
10
  end
15
11
  end
16
12
 
17
13
  def group_by_period(period, field, permit: nil, **options)
18
- # to_sym is unsafe on user input, so convert to strings
19
- permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
20
- if permitted_periods.include?(period.to_s)
21
- send("group_by_#{period}", field, **options)
22
- else
23
- raise ArgumentError, "Unpermitted period"
24
- end
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
25
16
  end
26
17
  end
27
18
  end
@@ -27,24 +27,26 @@ module Groupdate
27
27
  adapter_name = @relation.connection.adapter_name
28
28
  query =
29
29
  case adapter_name
30
- when "MySQL", "Mysql2", "Mysql2Spatial", 'Mysql2Rgeo'
30
+ when "Mysql2", "Mysql2Spatial", "Mysql2Rgeo"
31
+ day_start_column = "CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL ? second"
32
+
31
33
  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 :day_of_year
35
- ["DAYOFYEAR(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
36
- when :hour_of_day
37
- ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
38
34
  when :minute_of_hour
39
- ["(EXTRACT(MINUTE from CONVERT_TZ(#{column}, '+00:00', ?)))", time_zone]
35
+ ["MINUTE(#{day_start_column})", time_zone, day_start]
36
+ when :hour_of_day
37
+ ["HOUR(#{day_start_column})", time_zone, day_start]
38
+ when :day_of_week
39
+ ["DAYOFWEEK(#{day_start_column}) - 1", time_zone, day_start]
40
40
  when :day_of_month
41
- ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
41
+ ["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
42
+ when :day_of_year
43
+ ["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
42
44
  when :month_of_year
43
- ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
45
+ ["MONTH(#{day_start_column})", time_zone, day_start]
44
46
  when :week
45
- ["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]
47
+ ["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]
46
48
  when :quarter
47
- ["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]
49
+ ["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]
48
50
  else
49
51
  format =
50
52
  case period
@@ -62,49 +64,56 @@ module Groupdate
62
64
  "%Y-01-01 00:00:00"
63
65
  end
64
66
 
65
- ["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]
67
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
66
68
  end
67
69
  when "PostgreSQL", "PostGIS"
70
+ day_start_column = "#{column}::timestamptz AT TIME ZONE ? - INTERVAL ?"
71
+ day_start_interval = "#{day_start} second"
72
+
68
73
  case period
69
- when :day_of_week
70
- ["EXTRACT(DOW from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
71
- when :day_of_year
72
- ["EXTRACT(DOY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
73
- when :hour_of_day
74
- ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
75
74
  when :minute_of_hour
76
- ["EXTRACT(MINUTE from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
75
+ ["EXTRACT(MINUTE FROM #{day_start_column})::integer", time_zone, day_start_interval]
76
+ when :hour_of_day
77
+ ["EXTRACT(HOUR FROM #{day_start_column})::integer", time_zone, day_start_interval]
78
+ when :day_of_week
79
+ ["EXTRACT(DOW FROM #{day_start_column})::integer", time_zone, day_start_interval]
77
80
  when :day_of_month
78
- ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
81
+ ["EXTRACT(DAY FROM #{day_start_column})::integer", time_zone, day_start_interval]
82
+ when :day_of_year
83
+ ["EXTRACT(DOY FROM #{day_start_column})::integer", time_zone, day_start_interval]
79
84
  when :month_of_year
80
- ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
81
- when :week # start on Sunday, not PostgreSQL default Monday
82
- ["(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]
85
+ ["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
86
+ when :week
87
+ ["(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]
83
88
  else
84
- ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
89
+ if day_start == 0
90
+ # prettier
91
+ ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
92
+ else
93
+ ["(DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?) AT TIME ZONE ?", period, time_zone, day_start_interval, day_start_interval, time_zone]
94
+ end
85
95
  end
86
96
  when "SQLite"
87
97
  raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
88
98
  raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
89
- raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
90
99
 
91
100
  if period == :week
92
- ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
101
+ ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
93
102
  else
94
103
  format =
95
104
  case period
96
- when :hour_of_day
97
- "%H"
98
105
  when :minute_of_hour
99
106
  "%M"
107
+ when :hour_of_day
108
+ "%H"
100
109
  when :day_of_week
101
110
  "%w"
102
111
  when :day_of_month
103
112
  "%d"
104
- when :month_of_year
105
- "%m"
106
113
  when :day_of_year
107
114
  "%j"
115
+ when :month_of_year
116
+ "%m"
108
117
  when :second
109
118
  "%Y-%m-%d %H:%M:%S UTC"
110
119
  when :minute
@@ -121,39 +130,38 @@ module Groupdate
121
130
  "%Y-01-01 00:00:00 UTC"
122
131
  end
123
132
 
124
- ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
133
+ ["strftime(?, #{column})", format]
125
134
  end
126
135
  when "Redshift"
136
+ day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
137
+ day_start_interval = "#{day_start} second"
138
+
127
139
  case period
128
- when :day_of_week
129
- ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
130
- when :hour_of_day
131
- ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
132
140
  when :minute_of_hour
133
- ["EXTRACT(MINUTE from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
141
+ ["EXTRACT(MINUTE from #{day_start_column})::integer", time_zone, day_start_interval]
142
+ when :hour_of_day
143
+ ["EXTRACT(HOUR from #{day_start_column})::integer", time_zone, day_start_interval]
144
+ when :day_of_week
145
+ ["EXTRACT(DOW from #{day_start_column})::integer", time_zone, day_start_interval]
134
146
  when :day_of_month
135
- ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
147
+ ["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
136
148
  when :day_of_year
137
- ["EXTRACT(DOY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
149
+ ["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
138
150
  when :month_of_year
139
- ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
151
+ ["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
140
152
  when :week # start on Sunday, not Redshift default Monday
141
153
  # Redshift does not return timezone information; it
142
154
  # always says it is in UTC time, so we must convert
143
155
  # back to UTC to play properly with the rest of Groupdate.
144
- #
145
- ["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]
156
+ week_start_interval = "#{week_start} day"
157
+ ["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]
146
158
  else
147
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
159
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?)::timestamp", time_zone, period, time_zone, day_start_interval, day_start_interval]
148
160
  end
149
161
  else
150
162
  raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
151
163
  end
152
164
 
153
- if adapter_name == "MySQL" && period == :week
154
- query[0] = "CAST(#{query[0]} AS DATETIME)"
155
- end
156
-
157
165
  clause = @relation.send(:sanitize_sql_array, query)
158
166
 
159
167
  # cleaner queries in logs
@@ -166,11 +174,7 @@ module Groupdate
166
174
  end
167
175
 
168
176
  def clean_group_clause_mysql(clause)
169
- clause = clause.gsub("DATE_SUB(#{column}, INTERVAL 0 second)", "#{column}")
170
- if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
171
- clause = clause[9..-21]
172
- end
173
- clause
177
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
174
178
  end
175
179
 
176
180
  def where_clause
@@ -11,19 +11,54 @@ module Groupdate
11
11
  @day_start = day_start
12
12
  @options = options
13
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,17 @@ 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)
47
76
  time = time.to_time.in_time_zone(time_zone)
48
77
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
78
+ if day_start != 0
79
+ # apply day_start to a time object that's not affected by DST
80
+ time = change_zone.call(time, utc)
81
+ time -= day_start.seconds
82
+ end
51
83
 
52
84
  time =
53
85
  case period
@@ -60,9 +92,7 @@ module Groupdate
60
92
  when :day
61
93
  time.beginning_of_day
62
94
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
95
+ time.beginning_of_week(@week_start_key)
66
96
  when :month
67
97
  time.beginning_of_month
68
98
  when :quarter
@@ -74,7 +104,7 @@ module Groupdate
74
104
  when :minute_of_hour
75
105
  time.min
76
106
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
107
+ time.days_to_week_start(@week_start_key)
78
108
  when :day_of_month
79
109
  time.day
80
110
  when :month_of_year
@@ -85,21 +115,33 @@ module Groupdate
85
115
  raise Groupdate::Error, "Invalid period"
86
116
  end
87
117
 
88
- # only if day_start != 0 for performance
89
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
118
+ if day_start != 0 && time.is_a?(Time)
119
+ time += day_start.seconds
120
+ time = change_zone.call(time, time_zone)
121
+ end
90
122
 
91
123
  time
92
124
  end
93
125
 
126
+ def change_zone
127
+ @change_zone ||= begin
128
+ if ActiveSupport::VERSION::STRING >= "5.2"
129
+ ->(time, zone) { time.change(zone: zone) }
130
+ else
131
+ # TODO make more efficient
132
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
133
+ end
134
+ end
135
+ end
136
+
94
137
  def time_range
95
138
  @time_range ||= begin
96
139
  time_range = options[:range]
97
140
  if time_range.is_a?(Range) && time_range.first.is_a?(Date)
98
141
  # convert range of dates to range of times
99
- # use parsing instead of in_time_zone due to Rails < 4
100
- last = time_zone.parse(time_range.last.to_s)
142
+ last = time_range.last.in_time_zone(time_zone)
101
143
  last += 1.day unless time_range.exclude_end?
102
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
144
+ time_range = Range.new(time_range.first.in_time_zone(time_zone), last, true)
103
145
  elsif !time_range && options[:last]
104
146
  if period == :quarter
105
147
  step = 3.months
@@ -119,7 +161,8 @@ module Groupdate
119
161
  if options[:current] == false
120
162
  round_time(start_at - step)...round_time(now)
121
163
  else
122
- round_time(start_at)..now
164
+ # extend to end of current period
165
+ round_time(start_at)...(round_time(now) + step)
123
166
  end
124
167
  end
125
168
  end
@@ -199,34 +242,36 @@ module Groupdate
199
242
  end
200
243
 
201
244
  def key_format
202
- locale = options[:locale] || I18n.locale
203
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
245
+ @key_format ||= begin
246
+ locale = options[:locale] || I18n.locale
247
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
204
248
 
205
- if options[:format]
206
- if options[:format].respond_to?(:call)
207
- options[:format]
208
- else
209
- sunday = time_zone.parse("2014-03-02 00:00:00")
210
- lambda do |key|
211
- case period
212
- when :hour_of_day
213
- key = sunday + key.hours + day_start.seconds
214
- when :minute_of_hour
215
- key = sunday + key.minutes + day_start.seconds
216
- when :day_of_week
217
- key = sunday + key.days + (week_start + 1).days
218
- when :day_of_month
219
- key = Date.new(2014, 1, key).to_time
220
- when :month_of_year
221
- key = Date.new(2014, key, 1).to_time
249
+ if options[:format]
250
+ if options[:format].respond_to?(:call)
251
+ options[:format]
252
+ else
253
+ sunday = time_zone.parse("2014-03-02 00:00:00")
254
+ lambda do |key|
255
+ case period
256
+ when :hour_of_day
257
+ key = sunday + key.hours + day_start.seconds
258
+ when :minute_of_hour
259
+ key = sunday + key.minutes + day_start.seconds
260
+ when :day_of_week
261
+ key = sunday + key.days + (week_start + 1).days
262
+ when :day_of_month
263
+ key = Date.new(2014, 1, key).to_time
264
+ when :month_of_year
265
+ key = Date.new(2014, key, 1).to_time
266
+ end
267
+ I18n.localize(key, format: options[:format], locale: locale)
222
268
  end
223
- I18n.localize(key, format: options[:format], locale: locale)
224
269
  end
270
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
271
+ lambda { |k| k.to_date }
272
+ else
273
+ lambda { |k| k }
225
274
  end
226
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
227
- lambda { |k| k.to_date }
228
- else
229
- lambda { |k| k }
230
275
  end
231
276
  end
232
277
 
@@ -246,23 +291,12 @@ module Groupdate
246
291
  end
247
292
  end
248
293
 
249
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
250
- keys = data.keys
251
- if multiple_groups
252
- keys.map! { |k| k[group_index] }
253
- keys.uniq!
254
- end
255
-
256
- keys.each do |key|
257
- if key != round_time(key)
258
- # only need to show what database returned since it will cast in Ruby time zone
259
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
260
- end
261
- end
262
- end
263
-
264
294
  def entire_series?(series_default)
265
295
  options.key?(:series) ? options[:series] : series_default
266
296
  end
297
+
298
+ def utc
299
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
300
+ end
267
301
  end
268
302
  end
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "4.3.0"
2
+ VERSION = "5.0.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: groupdate
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-12-27 00:00:00.000000000 Z
11
+ date: 2020-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport