groupdate 5.2.4 → 6.8.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: 2b6c83f192d439e35025586a076762413d80daab15b85e81561404bdf078acad
4
- data.tar.gz: 382dd612bbd1db8e131345981576b7a6c61c525d8a991fb8a3fc60678ce8d6ef
3
+ metadata.gz: a8f13f0acf7f0343411da5d22ddeef37e132971142ef849f72998caa78a32eba
4
+ data.tar.gz: c79112d5544778a1597e7723fc2b7ec6dfc45c906c7707aa74970ad007b01de0
5
5
  SHA512:
6
- metadata.gz: 2d33022921907669f28eed522daec36e84552aabd41a411ff556923c0029cc72a003d30cd9cbeecba27e3d795fc279f71ca05a01cbc8c5cf6a0bd7256a478f72
7
- data.tar.gz: 87c778a1972590cc6609a3c5a096fef6dde88c70ab663b5ebac820de89ec356359f802ea1d8ba38280378e877caabb1974e1d6a81c689eef92e6d7accf2f356a
6
+ metadata.gz: 750c32b3729bda086a69107fbccbed3be1b5e4ad5d56c8e266c8a896856ed1a2364ff537a8d0e78af161d2d6fc74ad23f666b99876216f597e4db44b83f85cc3
7
+ data.tar.gz: 9770b962557af2e2f6356a1594c93e3b60d781bd637c1b34e78a2d241cb7842740652bbec15c2a959a1d9dadf304ef71c159b9985219a1a295d8342013892f58
data/CHANGELOG.md CHANGED
@@ -1,3 +1,59 @@
1
+ ## 6.8.0 (2026-04-04)
2
+
3
+ - Dropped support for Ruby < 3.3 and Active Record < 7.2
4
+
5
+ ## 6.7.0 (2025-06-02)
6
+
7
+ - Added time zone support for SQLite
8
+ - Added `day_start` support for SQLite
9
+ - Added `group_by_quarter` support for SQLite
10
+
11
+ ## 6.6.0 (2025-05-04)
12
+
13
+ - Dropped support for Ruby < 3.2 and Active Record < 7.1
14
+
15
+ ## 6.5.1 (2024-10-07)
16
+
17
+ - Fixed connection leasing for Active Record 7.2+
18
+
19
+ ## 6.5.0 (2024-10-01)
20
+
21
+ - Added support for Active Record 8
22
+ - Dropped support for Ruby < 3.1 and Active Record < 7
23
+
24
+ ## 6.4.0 (2023-09-13)
25
+
26
+ - Added support for Trilogy
27
+
28
+ ## 6.3.0 (2023-07-02)
29
+
30
+ - Dropped support for Ruby < 3 and Rails < 6.1
31
+
32
+ ## 6.2.1 (2023-04-18)
33
+
34
+ - Fixed extra day with `DateTime` ranges
35
+
36
+ ## 6.2.0 (2023-01-29)
37
+
38
+ - Added support for async methods with Active Record 7.1
39
+
40
+ ## 6.1.0 (2022-04-05)
41
+
42
+ - Added `expand_range` option
43
+
44
+ ## 6.0.1 (2022-01-16)
45
+
46
+ - Fixed incorrect results (error before 6.0) with `includes` with Active Record 6.1+
47
+
48
+ ## 6.0.0 (2022-01-15)
49
+
50
+ - Raise `ActiveRecord::UnknownAttributeReference` for non-attribute arguments
51
+ - Raise `ArgumentError` for ranges with string bounds
52
+ - Added `n` option for Redshift
53
+ - Changed SQL to return dates instead of times for day, week, month, quarter, and year
54
+ - Removed `dates` option
55
+ - Dropped support for Ruby < 2.6 and Rails < 5.2
56
+
1
57
  ## 5.2.4 (2021-12-15)
2
58
 
3
59
  - Simplified queries for Active Record 7 and MySQL
@@ -140,7 +196,7 @@ Breaking changes
140
196
  - Added `default_value` option
141
197
  - Accept symbol for `format` option
142
198
  - Raise `ArgumentError` if no field specified
143
- - Added support for ActiveRecord 5 beta
199
+ - Added support for Active Record 5 beta
144
200
 
145
201
  ## 2.5.0 (2015-09-29)
146
202
 
@@ -154,18 +210,18 @@ Breaking changes
154
210
  - Added `carry_forward` option
155
211
  - Added `series: false` option for arrays and hashes
156
212
  - Fixed issue w/ Brasilia Summer Time
157
- - Fixed issues w/ ActiveRecord 4.2
213
+ - Fixed issues w/ Active Record 4.2
158
214
 
159
215
  ## 2.3.0 (2014-08-31)
160
216
 
161
- - Raise error when ActiveRecord::Base.default_timezone is not `:utc`
217
+ - Raise error when `ActiveRecord::Base.default_timezone` is not `:utc`
162
218
  - Added `day_of_month`
163
219
  - Added `month_of_year`
164
220
  - Do not quote column name
165
221
 
166
222
  ## 2.2.1 (2014-06-23)
167
223
 
168
- - Fixed ActiveRecord 3 associations
224
+ - Fixed Active Record 3 associations
169
225
 
170
226
  ## 2.2.0 (2014-06-22)
171
227
 
@@ -230,3 +286,44 @@ Breaking changes
230
286
  ## 1.0.0 (2013-05-15)
231
287
 
232
288
  - First major release
289
+
290
+ ## 0.1.6 (2013-05-07)
291
+
292
+ - Fixed error with Ruby 1.8
293
+
294
+ ## 0.1.5 (2013-05-01)
295
+
296
+ - Added support for getting the entire series
297
+
298
+ ## 0.1.4 (2013-05-01)
299
+
300
+ - Added support for Ruby 1.8
301
+
302
+ ## 0.1.3 (2013-04-25)
303
+
304
+ - Improved field names for ordering
305
+
306
+ ## 0.1.2 (2013-04-24)
307
+
308
+ - Added ability to order easily
309
+
310
+ ## 0.1.1 (2013-04-21)
311
+
312
+ - Improved return types for MySQL
313
+
314
+ ## 0.1.0 (2013-04-21)
315
+
316
+ - Added `day_of_week`
317
+ - Added `hour_of_day`
318
+
319
+ ## 0.0.3 (2013-04-18)
320
+
321
+ - Added support for Rails time zone strings
322
+
323
+ ## 0.0.2 (2013-04-18)
324
+
325
+ - Added tests
326
+
327
+ ## 0.0.1 (2013-04-17)
328
+
329
+ - First release
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2021 Andrew Kane
1
+ Copyright (c) 2013-2025 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -11,30 +11,30 @@ The simplest way to group by:
11
11
 
12
12
  :cake: Get the entire series - **the other best part**
13
13
 
14
- Supports PostgreSQL, MySQL, and Redshift, plus arrays and hashes (and limited support for [SQLite](#for-sqlite))
14
+ Supports PostgreSQL, MySQL, MariaDB, SQLite, and Redshift, plus arrays and hashes
15
15
 
16
16
  :cupid: Goes hand in hand with [Chartkick](https://www.chartkick.com)
17
17
 
18
- [![Build Status](https://github.com/ankane/groupdate/workflows/build/badge.svg?branch=master)](https://github.com/ankane/groupdate/actions)
18
+ [![Build Status](https://github.com/ankane/groupdate/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/groupdate/actions)
19
19
 
20
20
  ## Installation
21
21
 
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
- For MySQL and SQLite, also follow [these instructions](#additional-instructions).
28
+ For MySQL and MariaDB, also follow [these instructions](#additional-instructions).
29
29
 
30
30
  ## Getting Started
31
31
 
32
32
  ```ruby
33
33
  User.group_by_day(:created_at).count
34
34
  # {
35
- # Sat, 24 May 2020 => 50,
36
- # Sun, 25 May 2020 => 100,
37
- # Mon, 26 May 2020 => 34
35
+ # Wed, 01 Jan 2025 => 50,
36
+ # Thu, 02 Jan 2025 => 100,
37
+ # Fri, 03 Jan 2025 => 34
38
38
  # }
39
39
  ```
40
40
 
@@ -75,9 +75,9 @@ or
75
75
  ```ruby
76
76
  User.group_by_week(:created_at, time_zone: "Pacific Time (US & Canada)").count
77
77
  # {
78
- # Sun, 08 Mar 2020 => 70,
79
- # Sun, 15 Mar 2020 => 54,
80
- # Sun, 22 Mar 2020 => 80
78
+ # Sun, 05 Jan 2025 => 70,
79
+ # Sun, 12 Jan 2025 => 54,
80
+ # Sun, 19 Jan 2025 => 80
81
81
  # }
82
82
  ```
83
83
 
@@ -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
@@ -148,8 +154,8 @@ To get keys in a different format, use:
148
154
  ```ruby
149
155
  User.group_by_month(:created_at, format: "%b %Y").count
150
156
  # {
151
- # "Jan 2020" => 10
152
- # "Feb 2020" => 12
157
+ # "Jan 2025" => 10
158
+ # "Feb 2025" => 12
153
159
  # }
154
160
  ```
155
161
 
@@ -164,7 +170,7 @@ User.group_by_hour_of_day(:created_at, format: "%-l %P").count
164
170
  # }
165
171
  ```
166
172
 
167
- Takes a `String`, which is passed to [strftime](http://strfti.me/), or a `Symbol`, which is looked up by `I18n.localize` in `i18n` scope 'time.formats', or a `Proc`. You can pass a locale with the `locale` option.
173
+ Takes a `String`, which is passed to [strftime](https://www.strfti.me/), or a `Symbol`, which is looked up by `I18n.localize` in `i18n` scope 'time.formats', or a `Proc`. You can pass a locale with the `locale` option.
168
174
 
169
175
  ### Series
170
176
 
@@ -210,19 +216,6 @@ 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
214
-
215
- If passing user input as the column, be sure to sanitize it first [like you must](https://rails-sqli.org/) with `group`.
216
-
217
- ```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
- ```
225
-
226
219
  ### Default Scopes
227
220
 
228
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:
@@ -252,12 +245,14 @@ users.group_by_day(series: true) { |u| u.created_at }
252
245
  Count
253
246
 
254
247
  ```ruby
255
- 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] }
256
249
  ```
257
250
 
258
251
  ## Additional Instructions
259
252
 
260
- ### For MySQL
253
+ <a name="for-mysql"></a>
254
+
255
+ ### For MySQL and MariaDB
261
256
 
262
257
  [Time zone support](https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html) must be installed on the server.
263
258
 
@@ -273,30 +268,6 @@ SELECT CONVERT_TZ(NOW(), '+00:00', 'Pacific/Honolulu');
273
268
 
274
269
  It should return the time instead of `NULL`.
275
270
 
276
- ### For SQLite
277
-
278
- Groupdate has limited support for SQLite.
279
-
280
- - No time zone support
281
- - No `day_start` option
282
- - No `group_by_quarter` method
283
-
284
- If your application’s time zone is set to something other than `Etc/UTC` (the default), create an initializer with:
285
-
286
- ```ruby
287
- Groupdate.time_zone = false
288
- ```
289
-
290
- ## Upgrading
291
-
292
- ### 5.0
293
-
294
- Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
295
-
296
- - The `week_start` option is now supported for SQLite
297
- - The `day_start` option is now consistent between Active Record and enumerable
298
- - Deprecated positional arguments for time zone and range have been removed
299
-
300
271
  ## History
301
272
 
302
273
  View the [changelog](https://github.com/ankane/groupdate/blob/master/CHANGELOG.md)
@@ -3,27 +3,19 @@ module Groupdate
3
3
  class BaseAdapter
4
4
  attr_reader :period, :column, :day_start, :week_start, :n_seconds
5
5
 
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
-
6
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:, adapter_name: nil)
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
15
12
  @week_start = week_start
16
13
  @day_start = day_start
17
14
  @n_seconds = n_seconds
15
+ @adapter_name = adapter_name
18
16
 
19
- if ActiveRecord::VERSION::MAJOR >= 7
20
- if ActiveRecord.default_timezone == :local
21
- raise Groupdate::Error, "ActiveRecord.default_timezone must be :utc to use Groupdate"
22
- end
23
- else
24
- if relation.default_timezone == :local
25
- raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
26
- end
17
+ if ActiveRecord.default_timezone == :local
18
+ raise Groupdate::Error, "ActiveRecord.default_timezone must be :utc to use Groupdate"
27
19
  end
28
20
  end
29
21
 
@@ -49,28 +41,6 @@ module Groupdate
49
41
  ["#{column} IS NOT NULL"]
50
42
  end
51
43
  end
52
-
53
- # basic version of Active Record disallow_raw_sql!
54
- # symbol = column (safe), Arel node = SQL (safe), other = untrusted
55
- # matches table.column and column
56
- def validate_column(column)
57
- unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
58
- column = column.to_s
59
- unless /\A\w+(\.\w+)?\z/i.match(column)
60
- warn "[groupdate] Non-attribute argument: #{column}. Use Arel.sql() for known-safe values. This will raise an error in Groupdate 6"
61
- end
62
- end
63
- column
64
- end
65
-
66
- # resolves eagerly
67
- # need to convert both where_clause (easy)
68
- # and group_clause (not easy) if want to avoid this
69
- def resolve_column(relation, column)
70
- node = relation.send(:relation).send(:arel_columns, [column]).first
71
- node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
72
- relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
73
- end
74
44
  end
75
45
  end
76
46
  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]
@@ -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 @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))
@@ -2,12 +2,9 @@ module Groupdate
2
2
  module Adapters
3
3
  class SQLiteAdapter < BaseAdapter
4
4
  def group_clause
5
- raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
6
- raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
7
-
8
5
  query =
9
6
  if period == :week
10
- ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
7
+ ["strftime('%Y-%m-%d', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
11
8
  elsif period == :custom
12
9
  ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
13
10
  else
@@ -32,20 +29,58 @@ module Groupdate
32
29
  when :hour
33
30
  "%Y-%m-%d %H:00:00 UTC"
34
31
  when :day
35
- "%Y-%m-%d 00:00:00 UTC"
32
+ "%Y-%m-%d"
36
33
  when :month
37
- "%Y-%m-01 00:00:00 UTC"
34
+ "%Y-%m-01"
38
35
  when :quarter
39
- raise Groupdate::Error, "Quarter not supported for SQLite"
36
+ nil
40
37
  else # year
41
- "%Y-01-01 00:00:00 UTC"
38
+ "%Y-01-01"
42
39
  end
43
40
 
44
41
  ["strftime(?, #{column})", format]
45
42
  end
46
43
 
44
+ if period != :custom && (@time_zone != SeriesBuilder.utc || day_start != 0 || period == :quarter)
45
+ setup_function
46
+ week_start = period == :week ? Groupdate::Magic::DAYS[self.week_start].to_s : nil
47
+ query = ["groupdate_internal(?, #{column}, ?, ?, ?)", period, @time_zone.tzinfo.name, day_start, week_start]
48
+ end
49
+
47
50
  @relation.send(:sanitize_sql_array, query)
48
51
  end
52
+
53
+ private
54
+
55
+ def setup_function
56
+ @relation.connection_pool.with_connection do |connection|
57
+ raw_connection = connection.raw_connection
58
+ return if raw_connection.instance_variable_defined?(:@groupdate_function)
59
+
60
+ utc = SeriesBuilder.utc
61
+ date_periods = %i[day week month quarter year]
62
+
63
+ # note: this function is part of the internal API and may change between releases
64
+ # TODO improve performance
65
+ raw_connection.create_function("groupdate_internal", 4) do |func, period, value, time_zone, day_start, week_start|
66
+ if value.nil?
67
+ func.result = nil
68
+ else
69
+ period = period.to_sym
70
+ # cast_result handles week_start for day_of_week
71
+ week_start = :sunday if period == :day_of_week
72
+ result = SeriesBuilder.round_time(utc.parse(value), period, ActiveSupport::TimeZone[time_zone], day_start.to_i, week_start&.to_sym)
73
+ if date_periods.include?(period)
74
+ result = result.strftime("%Y-%m-%d")
75
+ elsif result.is_a?(Time)
76
+ result = result.in_time_zone(utc).strftime("%Y-%m-%d %H:%M:%S")
77
+ end
78
+ func.result = result
79
+ end
80
+ end
81
+ raw_connection.instance_variable_set(:@groupdate_function, true)
82
+ end
83
+ end
49
84
  end
50
85
  end
51
86
  end
@@ -5,7 +5,7 @@ module Enumerable
5
5
  raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
6
  Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
7
7
  elsif respond_to?(:scoping)
8
- scoping { @klass.group_by_period(period, *args, **options, &block) }
8
+ scoping { klass.group_by_period(period, *args, **options, &block) }
9
9
  else
10
10
  raise ArgumentError, "no block given"
11
11
  end
@@ -19,7 +19,7 @@ module Enumerable
19
19
  Groupdate::Magic.validate_period(period, options.delete(:permit))
20
20
  send("group_by_#{period}", **options, &block)
21
21
  else
22
- scoping { @klass.group_by_period(period, *args, **options, &block) }
22
+ scoping { klass.group_by_period(period, *args, **options, &block) }
23
23
  end
24
24
  end
25
25
  end
@@ -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) }
@@ -166,12 +180,13 @@ module Groupdate
166
180
  end
167
181
 
168
182
  def time_zone_support?(relation)
169
- if relation.connection.adapter_name =~ /mysql/i
170
- # need to call klass for Rails < 5.2
171
- sql = relation.klass.send(:sanitize_sql_array, ["SELECT CONVERT_TZ(NOW(), '+00:00', ?)", time_zone.tzinfo.name])
172
- !relation.connection.select_all(sql).first.values.first.nil?
173
- else
174
- true
183
+ relation.connection_pool.with_connection do |connection|
184
+ if connection.adapter_name.match?(/mysql|trilogy/i)
185
+ sql = relation.send(:sanitize_sql_array, ["SELECT CONVERT_TZ(NOW(), '+00:00', ?)", time_zone.tzinfo.name])
186
+ !connection.select_all(sql).to_a.first.values.first.nil?
187
+ else
188
+ true
189
+ end
175
190
  end
176
191
  end
177
192
 
@@ -189,21 +204,26 @@ module Groupdate
189
204
  def self.generate_relation(relation, field:, **options)
190
205
  magic = Groupdate::Magic::Relation.new(**options)
191
206
 
192
- adapter_name = relation.connection.adapter_name
207
+ adapter_name = relation.connection_pool.with_connection { |c| c.adapter_name }
193
208
  adapter = Groupdate.adapters[adapter_name]
194
209
  raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
195
210
 
211
+ # very important
212
+ column = validate_column(field)
213
+ column = resolve_column(relation, column)
214
+
196
215
  # generate ActiveRecord relation
197
216
  relation =
198
217
  adapter.new(
199
218
  relation,
200
- column: field,
219
+ column: column,
201
220
  period: magic.period,
202
221
  time_zone: magic.time_zone,
203
222
  time_range: magic.time_range,
204
223
  week_start: magic.week_start,
205
224
  day_start: magic.day_start,
206
- n_seconds: magic.n_seconds
225
+ n_seconds: magic.n_seconds,
226
+ adapter_name: adapter_name
207
227
  ).generate
208
228
 
209
229
  # add Groupdate info
@@ -213,9 +233,33 @@ module Groupdate
213
233
  relation
214
234
  end
215
235
 
236
+ class << self
237
+ # basic version of Active Record disallow_raw_sql!
238
+ # symbol = column (safe), Arel node = SQL (safe), other = untrusted
239
+ # matches table.column and column
240
+ def validate_column(column)
241
+ unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
242
+ column = column.to_s
243
+ unless /\A\w+(\.\w+)?\z/i.match?(column)
244
+ raise ActiveRecord::UnknownAttributeReference, "Query method called with non-attribute argument(s): #{column.inspect}. Use Arel.sql() for known-safe values."
245
+ end
246
+ end
247
+ column
248
+ end
249
+
250
+ # resolves eagerly
251
+ # need to convert both where_clause (easy)
252
+ # and group_clause (not easy) if want to avoid this
253
+ def resolve_column(relation, column)
254
+ node = relation.send(:relation).send(:arel_columns, [column]).first
255
+ node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
256
+ relation.connection_pool.with_connection { |c| c.visitor.accept(node, Arel::Collectors::SQLString.new).value }
257
+ end
258
+ end
259
+
216
260
  # allow any options to keep flexible for future
217
261
  def self.process_result(relation, result, **options)
218
- relation.groupdate_values.reverse.each do |gv|
262
+ relation.groupdate_values.reverse_each do |gv|
219
263
  result = gv.perform(relation, result, default_value: options[:default_value])
220
264
  end
221
265
  result
@@ -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 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
@@ -77,11 +46,16 @@ module Groupdate
77
46
  return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
78
47
  end
79
48
 
80
- time = time.to_time.in_time_zone(time_zone)
49
+ # TODO avoid calling to_time on dates
50
+ self.class.round_time(time.to_time, period, time_zone, day_start, @week_start_key)
51
+ end
52
+
53
+ def self.round_time(time, period, time_zone, day_start, week_start_key)
54
+ time = time.in_time_zone(time_zone)
81
55
 
82
56
  if day_start != 0
83
57
  # apply day_start to a time object that's not affected by DST
84
- time = change_zone.call(time, utc)
58
+ time = time.change(zone: utc)
85
59
  time -= day_start.seconds
86
60
  end
87
61
 
@@ -96,7 +70,7 @@ module Groupdate
96
70
  when :day
97
71
  time.beginning_of_day
98
72
  when :week
99
- time.beginning_of_week(@week_start_key)
73
+ time.beginning_of_week(week_start_key)
100
74
  when :month
101
75
  time.beginning_of_month
102
76
  when :quarter
@@ -108,7 +82,7 @@ module Groupdate
108
82
  when :minute_of_hour
109
83
  time.min
110
84
  when :day_of_week
111
- time.days_to_week_start(@week_start_key)
85
+ time.days_to_week_start(week_start_key)
112
86
  when :day_of_month
113
87
  time.day
114
88
  when :month_of_year
@@ -121,23 +95,12 @@ module Groupdate
121
95
 
122
96
  if day_start != 0 && time.is_a?(Time)
123
97
  time += day_start.seconds
124
- time = change_zone.call(time, time_zone)
98
+ time = time.change(zone: time_zone)
125
99
  end
126
100
 
127
101
  time
128
102
  end
129
103
 
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
104
  def time_range
142
105
  @time_range ||= begin
143
106
  time_range = options[:range]
@@ -148,10 +111,6 @@ module Groupdate
148
111
  case v
149
112
  when nil, Date, Time
150
113
  # 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
114
  else
156
115
  raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
157
116
  end
@@ -164,37 +123,37 @@ module Groupdate
164
123
 
165
124
  finish = time_range.end
166
125
  finish = finish.in_time_zone(time_zone) if finish
167
- if time_range.end.is_a?(Date) && !exclude_end
126
+ if time_range.end.is_a?(Date) && !time_range.end.is_a?(DateTime) && !exclude_end
168
127
  finish += 1.day
169
128
  exclude_end = true
170
129
  end
171
130
 
131
+ if options[:expand_range]
132
+ start = round_time(start) if start
133
+ if finish && !(finish == round_time(finish) && exclude_end)
134
+ finish = round_time(finish) + step
135
+ exclude_end = true
136
+ end
137
+ end
138
+
172
139
  time_range = Range.new(start, finish, exclude_end)
173
140
  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
141
+ step = step()
142
+ raise ArgumentError, "Cannot use last option with #{period}" unless step
189
143
 
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
144
+ # loop instead of multiply to change start_at - see #151
145
+ start_at = now
146
+ (options[:last].to_i - 1).times do
147
+ start_at -= step
197
148
  end
149
+
150
+ time_range =
151
+ if options[:current] == false
152
+ round_time(start_at - step)...round_time(now)
153
+ else
154
+ # extend to end of current period
155
+ round_time(start_at)...(round_time(now) + step)
156
+ end
198
157
  end
199
158
  time_range
200
159
  end
@@ -256,13 +215,7 @@ module Groupdate
256
215
  if time_range.begin
257
216
  series = [round_time(time_range.begin)]
258
217
 
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
218
+ step = step()
266
219
 
267
220
  last_step = series.last
268
221
  day_start_hour = day_start / 3600
@@ -289,7 +242,6 @@ module Groupdate
289
242
  def key_format
290
243
  @key_format ||= begin
291
244
  locale = options[:locale] || I18n.locale
292
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
293
245
 
294
246
  if options[:format]
295
247
  if options[:format].respond_to?(:call)
@@ -312,7 +264,7 @@ module Groupdate
312
264
  I18n.localize(key, format: options[:format], locale: locale)
313
265
  end
314
266
  end
315
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
267
+ elsif [:day, :week, :month, :quarter, :year].include?(period)
316
268
  lambda { |k| k.to_date }
317
269
  else
318
270
  lambda { |k| k }
@@ -320,6 +272,16 @@ module Groupdate
320
272
  end
321
273
  end
322
274
 
275
+ def step
276
+ if period == :quarter
277
+ 3.months
278
+ elsif period == :custom
279
+ n_seconds
280
+ elsif 1.respond_to?(period)
281
+ 1.send(period)
282
+ end
283
+ end
284
+
323
285
  def handle_multiple(data, series, multiple_groups, group_index)
324
286
  reverse = options[:reverse]
325
287
 
@@ -340,7 +302,7 @@ module Groupdate
340
302
  options.key?(:series) ? options[:series] : series_default
341
303
  end
342
304
 
343
- def utc
305
+ def self.utc
344
306
  @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
345
307
  end
346
308
  end
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "5.2.4"
2
+ VERSION = "6.8.0"
3
3
  end
data/lib/groupdate.rb CHANGED
@@ -4,16 +4,15 @@ require "active_support/core_ext/module/attribute_accessors"
4
4
  require "active_support/time"
5
5
 
6
6
  # modules
7
- require "groupdate/magic"
8
- require "groupdate/series_builder"
9
- require "groupdate/version"
7
+ require_relative "groupdate/magic"
8
+ require_relative "groupdate/series_builder"
9
+ require_relative "groupdate/version"
10
10
 
11
11
  # adapters
12
- require "groupdate/adapters/base_adapter"
13
- require "groupdate/adapters/mysql_adapter"
14
- require "groupdate/adapters/postgresql_adapter"
15
- require "groupdate/adapters/redshift_adapter"
16
- require "groupdate/adapters/sqlite_adapter"
12
+ require_relative "groupdate/adapters/base_adapter"
13
+ require_relative "groupdate/adapters/mysql_adapter"
14
+ require_relative "groupdate/adapters/postgresql_adapter"
15
+ require_relative "groupdate/adapters/sqlite_adapter"
17
16
 
18
17
  module Groupdate
19
18
  class Error < RuntimeError; end
@@ -21,10 +20,9 @@ module Groupdate
21
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]
22
21
  METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
23
22
 
24
- mattr_accessor :week_start, :day_start, :time_zone, :dates
23
+ mattr_accessor :week_start, :day_start, :time_zone
25
24
  self.week_start = :sunday
26
25
  self.day_start = 0
27
- self.dates = true
28
26
 
29
27
  # api for gems like ActiveMedian
30
28
  def self.process_result(relation, result, **options)
@@ -45,13 +43,12 @@ module Groupdate
45
43
  end
46
44
  end
47
45
 
48
- Groupdate.register_adapter ["Mysql2", "Mysql2Spatial", "Mysql2Rgeo"], Groupdate::Adapters::MySQLAdapter
49
- Groupdate.register_adapter ["PostgreSQL", "PostGIS"], Groupdate::Adapters::PostgreSQLAdapter
50
- Groupdate.register_adapter "Redshift", Groupdate::Adapters::RedshiftAdapter
46
+ Groupdate.register_adapter ["Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"], Groupdate::Adapters::MySQLAdapter
47
+ Groupdate.register_adapter ["PostgreSQL", "PostGIS", "Redshift"], Groupdate::Adapters::PostgreSQLAdapter
51
48
  Groupdate.register_adapter "SQLite", Groupdate::Adapters::SQLiteAdapter
52
49
 
53
- require "groupdate/enumerable"
50
+ require_relative "groupdate/enumerable"
54
51
 
55
52
  ActiveSupport.on_load(:active_record) do
56
- require "groupdate/active_record"
53
+ require_relative "groupdate/active_record"
57
54
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: groupdate
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.4
4
+ version: 6.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2021-12-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -16,15 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '5'
18
+ version: '7.2'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '5'
27
- description:
25
+ version: '7.2'
28
26
  email: andrew@ankane.org
29
27
  executables: []
30
28
  extensions: []
@@ -39,7 +37,6 @@ files:
39
37
  - lib/groupdate/adapters/base_adapter.rb
40
38
  - lib/groupdate/adapters/mysql_adapter.rb
41
39
  - lib/groupdate/adapters/postgresql_adapter.rb
42
- - lib/groupdate/adapters/redshift_adapter.rb
43
40
  - lib/groupdate/adapters/sqlite_adapter.rb
44
41
  - lib/groupdate/enumerable.rb
45
42
  - lib/groupdate/magic.rb
@@ -51,7 +48,6 @@ homepage: https://github.com/ankane/groupdate
51
48
  licenses:
52
49
  - MIT
53
50
  metadata: {}
54
- post_install_message:
55
51
  rdoc_options: []
56
52
  require_paths:
57
53
  - lib
@@ -59,15 +55,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
59
55
  requirements:
60
56
  - - ">="
61
57
  - !ruby/object:Gem::Version
62
- version: '2.4'
58
+ version: '3.3'
63
59
  required_rubygems_version: !ruby/object:Gem::Requirement
64
60
  requirements:
65
61
  - - ">="
66
62
  - !ruby/object:Gem::Version
67
63
  version: '0'
68
64
  requirements: []
69
- rubygems_version: 3.2.32
70
- signing_key:
65
+ rubygems_version: 4.0.6
71
66
  specification_version: 4
72
67
  summary: The simplest way to group temporal data
73
68
  test_files: []
@@ -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