groupdate 5.2.2 → 6.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05ffc10283f2de5fa74bacff7a6368636f62e8ddb263dd33d191502c0b418edc
4
- data.tar.gz: 887827ef96299b9916f97e554706e670e83c99dad2522755c760348bdd3077cf
3
+ metadata.gz: ee8a614ef0b4d869d534df2d0bb972296d26edd2839e430547d54a8ffb22a2b0
4
+ data.tar.gz: f15a380692b7726895a3083a4bd5e013610f6583bce614ede9e2fefd06d30f60
5
5
  SHA512:
6
- metadata.gz: 5d8cdf01f590ff7575539e39e9207e14eac7bd04417483fe453a7f1fb281e9bf9869678fbb7e2cc2381ccdfb678f47c6ca82b644ea291149ed48fdfba61963a3
7
- data.tar.gz: 190dd79d14e7b00a7aa5492a46cfa18d50b56af08bd489fc8e68560530e0edb91a124932a24444116dd0cda09c926834c9ac33ed2b4b8c5117601e24b2ee0eac
6
+ metadata.gz: 6145ca25662c5df8cab197d570a1bf1b26e7492e0404faba390d4af793992fbb3405c4995136b6818ac0be6a6bc3ff4da2ee8288860fd5c2b9b4fadad036c9e6
7
+ data.tar.gz: aaccec08b13902b51c2eb8180bb5a0807e6d2905fdd5b658a3dfab74dd2b4f2fed96dffc2dc42edcf61fe0a4eb20e21efca6a86ef55402a1f9d594d7eecb77e1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## 6.2.0 (2023-01-29)
2
+
3
+ - Added support for async methods with Active Record 7.1
4
+
5
+ ## 6.1.0 (2022-04-05)
6
+
7
+ - Added `expand_range` option
8
+
9
+ ## 6.0.1 (2022-01-16)
10
+
11
+ - Fixed incorrect results (error before 6.0) with `includes` with Active Record 6.1+
12
+
13
+ ## 6.0.0 (2022-01-15)
14
+
15
+ - Raise `ActiveRecord::UnknownAttributeReference` for non-attribute arguments
16
+ - Raise `ArgumentError` for ranges with string bounds
17
+ - Added `n` option for Redshift
18
+ - Changed SQL to return dates instead of times for day, week, month, quarter, and year
19
+ - Removed `dates` option
20
+ - Dropped support for Ruby < 2.6 and Rails < 5.2
21
+
22
+ ## 5.2.4 (2021-12-15)
23
+
24
+ - Simplified queries for Active Record 7 and MySQL
25
+
26
+ ## 5.2.3 (2021-12-06)
27
+
28
+ - Fixed error and warnings with Active Record 7
29
+
1
30
  ## 5.2.2 (2021-02-08)
2
31
 
3
32
  - Added support for `nil..nil` ranges in `range` option
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2021 Andrew Kane
1
+ Copyright (c) 2013-2022 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -22,7 +22,7 @@ Supports PostgreSQL, MySQL, and Redshift, plus arrays and hashes (and limited su
22
22
  Add this line to your application’s Gemfile:
23
23
 
24
24
  ```ruby
25
- gem 'groupdate'
25
+ gem "groupdate"
26
26
  ```
27
27
 
28
28
  For MySQL and SQLite, also follow [these instructions](#additional-instructions).
@@ -119,6 +119,12 @@ To get a specific time range, use:
119
119
  User.group_by_day(:created_at, range: 2.weeks.ago.midnight..Time.now).count
120
120
  ```
121
121
 
122
+ To expand the range to the start and end of the time period, use:
123
+
124
+ ```ruby
125
+ User.group_by_day(:created_at, range: 2.weeks.ago..Time.now, expand_range: true).count
126
+ ```
127
+
122
128
  To get the most recent time periods, use:
123
129
 
124
130
  ```ruby
@@ -210,17 +216,12 @@ If grouping on date columns which don’t need time zone conversion, use:
210
216
  User.group_by_week(:created_on, time_zone: false).count
211
217
  ```
212
218
 
213
- ### User Input
219
+ ### Default Scopes
214
220
 
215
- If passing user input as the column, be sure to sanitize it first [like you must](https://rails-sqli.org/) with `group`.
221
+ If you use Postgres and have a default scope that uses `order`, you may get a `column must appear in the GROUP BY clause` error (just like with Active Record’s `group` method). Remove the `order` scope with:
216
222
 
217
223
  ```ruby
218
- column = params[:column]
219
-
220
- # check against permitted columns
221
- raise "Unpermitted column" unless ["column_a", "column_b"].include?(column)
222
-
223
- User.group_by_day(column).count
224
+ User.unscope(:order).group_by_day(:count).count
224
225
  ```
225
226
 
226
227
  ## Arrays and Hashes
@@ -244,7 +245,7 @@ users.group_by_day(series: true) { |u| u.created_at }
244
245
  Count
245
246
 
246
247
  ```ruby
247
- users.group_by_day { |u| u.created_at }.map { |k, v| [k, v.count] }.to_h
248
+ users.group_by_day { |u| u.created_at }.to_h { |k, v| [k, v.count] }
248
249
  ```
249
250
 
250
251
  ## Additional Instructions
@@ -257,8 +258,6 @@ users.group_by_day { |u| u.created_at }.map { |k, v| [k, v.count] }.to_h
257
258
  mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql
258
259
  ```
259
260
 
260
- or copy and paste [these statements](https://gist.githubusercontent.com/ankane/1d6b0022173186accbf0/raw/time_zone_support.sql) into a SQL console.
261
-
262
261
  You can confirm it worked with:
263
262
 
264
263
  ```sql
@@ -283,13 +282,15 @@ Groupdate.time_zone = false
283
282
 
284
283
  ## Upgrading
285
284
 
286
- ### 5.0
285
+ ### 6.0
286
+
287
+ Groupdate 6.0 protects against unsafe input by default. For non-attribute arguments, use:
287
288
 
288
- Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
289
+ ```ruby
290
+ User.group_by_day(Arel.sql(known_safe_value)).count
291
+ ```
289
292
 
290
- - The `week_start` option is now supported for SQLite
291
- - The `day_start` option is now consistent between Active Record and enumerable
292
- - Deprecated positional arguments for time zone and range have been removed
293
+ Also, the `dates` option has been removed.
293
294
 
294
295
  ## History
295
296
 
@@ -4,11 +4,8 @@ module Groupdate
4
4
  attr_reader :period, :column, :day_start, :week_start, :n_seconds
5
5
 
6
6
  def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:)
7
- # very important
8
- column = validate_column(column)
9
-
10
7
  @relation = relation
11
- @column = resolve_column(relation, column)
8
+ @column = column
12
9
  @period = period
13
10
  @time_zone = time_zone
14
11
  @time_range = time_range
@@ -16,8 +13,14 @@ module Groupdate
16
13
  @day_start = day_start
17
14
  @n_seconds = n_seconds
18
15
 
19
- if relation.default_timezone == :local
20
- raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
16
+ if ActiveRecord::VERSION::MAJOR >= 7
17
+ if ActiveRecord.default_timezone == :local
18
+ raise Groupdate::Error, "ActiveRecord.default_timezone must be :utc to use Groupdate"
19
+ end
20
+ else
21
+ if relation.default_timezone == :local
22
+ raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
23
+ end
21
24
  end
22
25
  end
23
26
 
@@ -43,28 +46,6 @@ module Groupdate
43
46
  ["#{column} IS NOT NULL"]
44
47
  end
45
48
  end
46
-
47
- # basic version of Active Record disallow_raw_sql!
48
- # symbol = column (safe), Arel node = SQL (safe), other = untrusted
49
- # matches table.column and column
50
- def validate_column(column)
51
- unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
52
- column = column.to_s
53
- unless /\A\w+(\.\w+)?\z/i.match(column)
54
- warn "[groupdate] Non-attribute argument: #{column}. Use Arel.sql() for known-safe values. This will raise an error in Groupdate 6"
55
- end
56
- end
57
- column
58
- end
59
-
60
- # resolves eagerly
61
- # need to convert both where_clause (easy)
62
- # and group_clause (not easy) if want to avoid this
63
- def resolve_column(relation, column)
64
- node = relation.send(:relation).send(:arel_columns, [column]).first
65
- node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
66
- relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
67
- end
68
49
  end
69
50
  end
70
51
  end
@@ -20,9 +20,21 @@ module Groupdate
20
20
  when :month_of_year
21
21
  ["MONTH(#{day_start_column})", time_zone, day_start]
22
22
  when :week
23
- ["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]
23
+ ["CAST(DATE_FORMAT(#{day_start_column} - INTERVAL ((? + DAYOFWEEK(#{day_start_column})) % 7) DAY, '%Y-%m-%d') AS DATE)", time_zone, day_start, 12 - week_start, time_zone, day_start]
24
24
  when :quarter
25
- ["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]
25
+ ["CAST(CONCAT(YEAR(#{day_start_column}), '-', LPAD(1 + 3 * (QUARTER(#{day_start_column}) - 1), 2, '00'), '-01') AS DATE)", time_zone, day_start, time_zone, day_start]
26
+ when :day, :month, :year
27
+ format =
28
+ case period
29
+ when :day
30
+ "%Y-%m-%d"
31
+ when :month
32
+ "%Y-%m-01"
33
+ else # year
34
+ "%Y-01-01"
35
+ end
36
+
37
+ ["CAST(DATE_FORMAT(#{day_start_column}, ?) AS DATE)", time_zone, day_start, format]
26
38
  when :custom
27
39
  ["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
28
40
  else
@@ -32,14 +44,8 @@ module Groupdate
32
44
  "%Y-%m-%d %H:%i:%S"
33
45
  when :minute
34
46
  "%Y-%m-%d %H:%i:00"
35
- when :hour
47
+ else # hour
36
48
  "%Y-%m-%d %H:00:00"
37
- when :day
38
- "%Y-%m-%d 00:00:00"
39
- when :month
40
- "%Y-%m-01 00:00:00"
41
- else # year
42
- "%Y-01-01 00:00:00"
43
49
  end
44
50
 
45
51
  ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
@@ -49,7 +55,8 @@ module Groupdate
49
55
  end
50
56
 
51
57
  def clean_group_clause(clause)
52
- clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
58
+ # zero quoted in Active Record 7+
59
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "").gsub(/ (\-|\+) INTERVAL '0' second/, "")
53
60
  end
54
61
  end
55
62
  end
@@ -21,16 +21,18 @@ module Groupdate
21
21
  when :month_of_year
22
22
  ["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
23
23
  when :week
24
- ["(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]
24
+ ["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?)::date", time_zone, day_start_interval, 13 - week_start, time_zone, day_start_interval, day_start_interval]
25
25
  when :custom
26
- ["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
27
- else
28
- if day_start == 0
29
- # prettier
30
- ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
26
+ if @relation.connection.adapter_name == "Redshift"
27
+ ["TIMESTAMP 'epoch' + (FLOOR(EXTRACT(EPOCH FROM #{column}::timestamp) / ?) * ?) * INTERVAL '1 second'", n_seconds, n_seconds]
31
28
  else
32
- ["(DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?) AT TIME ZONE ?", period, time_zone, day_start_interval, day_start_interval, time_zone]
29
+ ["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
33
30
  end
31
+ when :day, :month, :quarter, :year
32
+ ["DATE_TRUNC(?, #{day_start_column})::date", period, time_zone, day_start_interval]
33
+ else
34
+ # day start is always 0 for seconds, minute, hour
35
+ ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
34
36
  end
35
37
 
36
38
  clean_group_clause(@relation.send(:sanitize_sql_array, query))
@@ -7,7 +7,7 @@ module Groupdate
7
7
 
8
8
  query =
9
9
  if period == :week
10
- ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
10
+ ["strftime('%Y-%m-%d', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
11
11
  elsif period == :custom
12
12
  ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
13
13
  else
@@ -32,13 +32,13 @@ module Groupdate
32
32
  when :hour
33
33
  "%Y-%m-%d %H:00:00 UTC"
34
34
  when :day
35
- "%Y-%m-%d 00:00:00 UTC"
35
+ "%Y-%m-%d"
36
36
  when :month
37
- "%Y-%m-01 00:00:00 UTC"
37
+ "%Y-%m-01"
38
38
  when :quarter
39
39
  raise Groupdate::Error, "Quarter not supported for SQLite"
40
40
  else # year
41
- "%Y-01-01 00:00:00 UTC"
41
+ "%Y-01-01"
42
42
  end
43
43
 
44
44
  ["strftime(?, #{column})", format]
@@ -22,7 +22,7 @@ module Groupdate
22
22
  end
23
23
 
24
24
  def validate_keywords
25
- known_keywords = [:time_zone, :dates, :series, :format, :locale, :range, :reverse]
25
+ known_keywords = [:time_zone, :series, :format, :locale, :range, :expand_range, :reverse]
26
26
 
27
27
  if %i[week day_of_week].include?(period)
28
28
  known_keywords << :week_start
@@ -125,6 +125,10 @@ module Groupdate
125
125
  end
126
126
 
127
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
+
128
132
  multiple_groups = relation.group_values.size > 1
129
133
 
130
134
  check_nils(result, multiple_groups, relation)
@@ -145,6 +149,16 @@ module Groupdate
145
149
  lambda { |k| k.to_i }
146
150
  when :day_of_week
147
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
148
162
  else
149
163
  utc = ActiveSupport::TimeZone["UTC"]
150
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) }
@@ -193,11 +207,15 @@ module Groupdate
193
207
  adapter = Groupdate.adapters[adapter_name]
194
208
  raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
195
209
 
210
+ # very important
211
+ column = validate_column(field)
212
+ column = resolve_column(relation, column)
213
+
196
214
  # generate ActiveRecord relation
197
215
  relation =
198
216
  adapter.new(
199
217
  relation,
200
- column: field,
218
+ column: column,
201
219
  period: magic.period,
202
220
  time_zone: magic.time_zone,
203
221
  time_range: magic.time_range,
@@ -213,6 +231,30 @@ module Groupdate
213
231
  relation
214
232
  end
215
233
 
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
246
+ end
247
+
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
257
+
216
258
  # allow any options to keep flexible for future
217
259
  def self.process_result(relation, result, **options)
218
260
  relation.groupdate_values.reverse.each do |gv|
@@ -9,6 +9,9 @@ module Groupdate
9
9
  end
10
10
 
11
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
+
12
15
  default_value = [:count, :sum].include?(args[0]) ? 0 : nil
13
16
  Groupdate.process_result(self, super, default_value: default_value)
14
17
  end
@@ -2,8 +2,6 @@ module Groupdate
2
2
  class SeriesBuilder
3
3
  attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
4
4
 
5
- CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
-
7
5
  def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
6
  @period = period
9
7
  @time_zone = time_zone
@@ -23,41 +21,12 @@ module Groupdate
23
21
  verified_data[k] = data.delete(k)
24
22
  end
25
23
 
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
-
55
24
  unless entire_series?(series_default)
56
25
  series = series.select { |k| verified_data[k] }
57
26
  end
58
27
 
59
28
  value = 0
60
- result = Hash[series.map do |k|
29
+ result = series.to_h do |k|
61
30
  value = verified_data[k] || (@options[:carry_forward] && value) || default_value
62
31
  key =
63
32
  if multiple_groups
@@ -67,7 +36,7 @@ module Groupdate
67
36
  end
68
37
 
69
38
  [key, value]
70
- end]
39
+ end
71
40
 
72
41
  result
73
42
  end
@@ -81,7 +50,7 @@ module Groupdate
81
50
 
82
51
  if day_start != 0
83
52
  # apply day_start to a time object that's not affected by DST
84
- time = change_zone.call(time, utc)
53
+ time = time.change(zone: utc)
85
54
  time -= day_start.seconds
86
55
  end
87
56
 
@@ -121,23 +90,12 @@ module Groupdate
121
90
 
122
91
  if day_start != 0 && time.is_a?(Time)
123
92
  time += day_start.seconds
124
- time = change_zone.call(time, time_zone)
93
+ time = time.change(zone: time_zone)
125
94
  end
126
95
 
127
96
  time
128
97
  end
129
98
 
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
-
141
99
  def time_range
142
100
  @time_range ||= begin
143
101
  time_range = options[:range]
@@ -148,10 +106,6 @@ module Groupdate
148
106
  case v
149
107
  when nil, Date, Time
150
108
  # good
151
- when String
152
- # TODO raise error in Groupdate 6
153
- warn "[groupdate] Range bounds should be Date or Time, not #{v.class.name}. This will raise an error in Groupdate 6"
154
- break
155
109
  else
156
110
  raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
157
111
  end
@@ -169,32 +123,32 @@ module Groupdate
169
123
  exclude_end = true
170
124
  end
171
125
 
126
+ if options[:expand_range]
127
+ start = round_time(start) if start
128
+ if finish && !(finish == round_time(finish) && exclude_end)
129
+ finish = round_time(finish) + step
130
+ exclude_end = true
131
+ end
132
+ end
133
+
172
134
  time_range = Range.new(start, finish, exclude_end)
173
135
  elsif !time_range && options[:last]
174
- if period == :quarter
175
- step = 3.months
176
- elsif period == :custom
177
- step = n_seconds
178
- elsif 1.respond_to?(period)
179
- step = 1.send(period)
180
- else
181
- raise ArgumentError, "Cannot use last option with #{period}"
182
- end
183
- if step
184
- # loop instead of multiply to change start_at - see #151
185
- start_at = now
186
- (options[:last].to_i - 1).times do
187
- start_at -= step
188
- end
136
+ step = step()
137
+ raise ArgumentError, "Cannot use last option with #{period}" unless step
189
138
 
190
- time_range =
191
- if options[:current] == false
192
- round_time(start_at - step)...round_time(now)
193
- else
194
- # extend to end of current period
195
- round_time(start_at)...(round_time(now) + step)
196
- end
139
+ # loop instead of multiply to change start_at - see #151
140
+ start_at = now
141
+ (options[:last].to_i - 1).times do
142
+ start_at -= step
197
143
  end
144
+
145
+ time_range =
146
+ if options[:current] == false
147
+ round_time(start_at - step)...round_time(now)
148
+ else
149
+ # extend to end of current period
150
+ round_time(start_at)...(round_time(now) + step)
151
+ end
198
152
  end
199
153
  time_range
200
154
  end
@@ -256,13 +210,7 @@ module Groupdate
256
210
  if time_range.begin
257
211
  series = [round_time(time_range.begin)]
258
212
 
259
- if period == :quarter
260
- step = 3.months
261
- elsif period == :custom
262
- step = n_seconds
263
- else
264
- step = 1.send(period)
265
- end
213
+ step = step()
266
214
 
267
215
  last_step = series.last
268
216
  day_start_hour = day_start / 3600
@@ -289,7 +237,6 @@ module Groupdate
289
237
  def key_format
290
238
  @key_format ||= begin
291
239
  locale = options[:locale] || I18n.locale
292
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
293
240
 
294
241
  if options[:format]
295
242
  if options[:format].respond_to?(:call)
@@ -312,7 +259,7 @@ module Groupdate
312
259
  I18n.localize(key, format: options[:format], locale: locale)
313
260
  end
314
261
  end
315
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
262
+ elsif [:day, :week, :month, :quarter, :year].include?(period)
316
263
  lambda { |k| k.to_date }
317
264
  else
318
265
  lambda { |k| k }
@@ -320,6 +267,16 @@ module Groupdate
320
267
  end
321
268
  end
322
269
 
270
+ def step
271
+ if period == :quarter
272
+ 3.months
273
+ elsif period == :custom
274
+ n_seconds
275
+ elsif 1.respond_to?(period)
276
+ 1.send(period)
277
+ end
278
+ end
279
+
323
280
  def handle_multiple(data, series, multiple_groups, group_index)
324
281
  reverse = options[:reverse]
325
282
 
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "5.2.2"
2
+ VERSION = "6.2.0"
3
3
  end
data/lib/groupdate.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # dependencies
2
+ require "active_support"
2
3
  require "active_support/core_ext/module/attribute_accessors"
3
4
  require "active_support/time"
4
5
 
@@ -11,7 +12,6 @@ require "groupdate/version"
11
12
  require "groupdate/adapters/base_adapter"
12
13
  require "groupdate/adapters/mysql_adapter"
13
14
  require "groupdate/adapters/postgresql_adapter"
14
- require "groupdate/adapters/redshift_adapter"
15
15
  require "groupdate/adapters/sqlite_adapter"
16
16
 
17
17
  module Groupdate
@@ -20,10 +20,9 @@ module Groupdate
20
20
  PERIODS = [:second, :minute, :hour, :day, :week, :month, :quarter, :year, :day_of_week, :hour_of_day, :minute_of_hour, :day_of_month, :day_of_year, :month_of_year]
21
21
  METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
22
22
 
23
- mattr_accessor :week_start, :day_start, :time_zone, :dates
23
+ mattr_accessor :week_start, :day_start, :time_zone
24
24
  self.week_start = :sunday
25
25
  self.day_start = 0
26
- self.dates = true
27
26
 
28
27
  # api for gems like ActiveMedian
29
28
  def self.process_result(relation, result, **options)
@@ -45,8 +44,7 @@ module Groupdate
45
44
  end
46
45
 
47
46
  Groupdate.register_adapter ["Mysql2", "Mysql2Spatial", "Mysql2Rgeo"], Groupdate::Adapters::MySQLAdapter
48
- Groupdate.register_adapter ["PostgreSQL", "PostGIS"], Groupdate::Adapters::PostgreSQLAdapter
49
- Groupdate.register_adapter "Redshift", Groupdate::Adapters::RedshiftAdapter
47
+ Groupdate.register_adapter ["PostgreSQL", "PostGIS", "Redshift"], Groupdate::Adapters::PostgreSQLAdapter
50
48
  Groupdate.register_adapter "SQLite", Groupdate::Adapters::SQLiteAdapter
51
49
 
52
50
  require "groupdate/enumerable"
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: 5.2.2
4
+ version: 6.2.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: 2021-02-08 00:00:00.000000000 Z
11
+ date: 2023-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5'
19
+ version: '5.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '5'
26
+ version: '5.2'
27
27
  description:
28
28
  email: andrew@ankane.org
29
29
  executables: []
@@ -39,7 +39,6 @@ files:
39
39
  - lib/groupdate/adapters/base_adapter.rb
40
40
  - lib/groupdate/adapters/mysql_adapter.rb
41
41
  - lib/groupdate/adapters/postgresql_adapter.rb
42
- - lib/groupdate/adapters/redshift_adapter.rb
43
42
  - lib/groupdate/adapters/sqlite_adapter.rb
44
43
  - lib/groupdate/enumerable.rb
45
44
  - lib/groupdate/magic.rb
@@ -59,14 +58,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
59
58
  requirements:
60
59
  - - ">="
61
60
  - !ruby/object:Gem::Version
62
- version: '2.4'
61
+ version: '2.6'
63
62
  required_rubygems_version: !ruby/object:Gem::Requirement
64
63
  requirements:
65
64
  - - ">="
66
65
  - !ruby/object:Gem::Version
67
66
  version: '0'
68
67
  requirements: []
69
- rubygems_version: 3.2.3
68
+ rubygems_version: 3.4.1
70
69
  signing_key:
71
70
  specification_version: 4
72
71
  summary: The simplest way to group temporal data
@@ -1,39 +0,0 @@
1
- module Groupdate
2
- module Adapters
3
- class RedshiftAdapter < BaseAdapter
4
- def group_clause
5
- time_zone = @time_zone.tzinfo.name
6
- day_start_column = "CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL ?"
7
- day_start_interval = "#{day_start} second"
8
-
9
- query =
10
- case period
11
- when :minute_of_hour
12
- ["EXTRACT(MINUTE from #{day_start_column})::integer", time_zone, day_start_interval]
13
- when :hour_of_day
14
- ["EXTRACT(HOUR from #{day_start_column})::integer", time_zone, day_start_interval]
15
- when :day_of_week
16
- ["EXTRACT(DOW from #{day_start_column})::integer", time_zone, day_start_interval]
17
- when :day_of_month
18
- ["EXTRACT(DAY from #{day_start_column})::integer", time_zone, day_start_interval]
19
- when :day_of_year
20
- ["EXTRACT(DOY from #{day_start_column})::integer", time_zone, day_start_interval]
21
- when :month_of_year
22
- ["EXTRACT(MONTH from #{day_start_column})::integer", time_zone, day_start_interval]
23
- when :week # start on Sunday, not Redshift default Monday
24
- # Redshift does not return timezone information; it
25
- # always says it is in UTC time, so we must convert
26
- # back to UTC to play properly with the rest of Groupdate.
27
- week_start_interval = "#{week_start} day"
28
- ["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]
29
- when :custom
30
- raise Groupdate::Error, "Not implemented yet"
31
- else
32
- ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?)::timestamp", time_zone, period, time_zone, day_start_interval, day_start_interval]
33
- end
34
-
35
- @relation.send(:sanitize_sql_array, query)
36
- end
37
- end
38
- end
39
- end