groupdate 4.3.0 → 5.2.2

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: 05ffc10283f2de5fa74bacff7a6368636f62e8ddb263dd33d191502c0b418edc
4
+ data.tar.gz: 887827ef96299b9916f97e554706e670e83c99dad2522755c760348bdd3077cf
5
5
  SHA512:
6
- metadata.gz: f7e3309e4f687cb88839c23d07f6ad9dce57ee3f8f18c335d695d79fe4c0d7fe4e236d1362071180ba07daf3eb6edd62ace05f4d15e0f4392c2dc48e81139c17
7
- data.tar.gz: 2b1682b63429bb482b01cb8f50a5bd0ab03a0677b9a806244c7d26054579611b1678f056c713d23278cd5cd3e8616def5c77d0897caccda7c5a6d297e4eaa863
6
+ metadata.gz: 5d8cdf01f590ff7575539e39e9207e14eac7bd04417483fe453a7f1fb281e9bf9869678fbb7e2cc2381ccdfb678f47c6ca82b644ea291149ed48fdfba61963a3
7
+ data.tar.gz: 190dd79d14e7b00a7aa5492a46cfa18d50b56af08bd489fc8e68560530e0edb91a124932a24444116dd0cda09c926834c9ac33ed2b4b8c5117601e24b2ee0eac
data/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 5.2.2 (2021-02-08)
2
+
3
+ - Added support for `nil..nil` ranges in `range` option
4
+
5
+ ## 5.2.1 (2020-09-09)
6
+
7
+ - Improved error message for invalid ranges
8
+ - Fixed bug with date string ranges
9
+
10
+ ## 5.2.0 (2020-09-07)
11
+
12
+ - Added warning for non-attribute argument
13
+ - Added support for beginless and endless ranges in `range` option
14
+
15
+ ## 5.1.0 (2020-07-30)
16
+
17
+ - Added `n` option to minute and second for custom durations
18
+
19
+ ## 5.0.0 (2020-02-18)
20
+
21
+ - Added support for `week_start` for SQLite
22
+ - Added support for full weekday names
23
+ - Made `day_start` behavior consistent between Active Record and enumerable
24
+ - Made `last` option extend to end of current period
25
+ - Raise error when `day_start` and `week_start` passed to unsupported methods
26
+ - The `day_start` option no longer applies to shorter periods
27
+ - Fixed `inconsistent time zone info` errors around DST with MySQL and PostgreSQL
28
+ - Improved performance of `format` option
29
+ - Removed deprecated positional arguments for time zone and range
30
+ - Dropped support for `mysql` gem (last release was 2013)
31
+
1
32
  ## 4.3.0 (2019-12-26)
2
33
 
3
34
  - Fixed error with empty results in Ruby 2.7
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2019 Andrew Kane
1
+ Copyright (c) 2013-2021 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -15,7 +15,7 @@ Supports PostgreSQL, MySQL, and Redshift, plus arrays and hashes (and limited su
15
15
 
16
16
  :cupid: Goes hand in hand with [Chartkick](https://www.chartkick.com)
17
17
 
18
- [![Build Status](https://travis-ci.org/ankane/groupdate.svg?branch=master)](https://travis-ci.org/ankane/groupdate)
18
+ [![Build Status](https://github.com/ankane/groupdate/workflows/build/badge.svg?branch=master)](https://github.com/ankane/groupdate/actions)
19
19
 
20
20
  ## Installation
21
21
 
@@ -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,13 +53,14 @@ 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
59
60
  - day_of_year
60
61
  - month_of_year
61
62
 
62
- Use it anywhere you can use `group`. Works with `count`, `sum`, `minimum`, `maximum`, and `average`. For `median`, check out [ActiveMedian](https://github.com/ankane/active_median).
63
+ Use it anywhere you can use `group`. Works with `count`, `sum`, `minimum`, `maximum`, and `average`. For `median` and `percentile`, check out [ActiveMedian](https://github.com/ankane/active_median).
63
64
 
64
65
  ### Time Zones
65
66
 
@@ -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
 
@@ -193,6 +194,14 @@ User.group_by_period(params[:period], :created_at, permit: ["day", "week"]).coun
193
194
 
194
195
  Raises an `ArgumentError` for unpermitted periods.
195
196
 
197
+ ### Custom Duration
198
+
199
+ To group by a specific number of minutes or seconds, use:
200
+
201
+ ```ruby
202
+ User.group_by_minute(:created_at, n: 10).count # 10 minutes
203
+ ```
204
+
196
205
  ### Date Columns
197
206
 
198
207
  If grouping on date columns which don’t need time zone conversion, use:
@@ -263,7 +272,7 @@ It should return the time instead of `NULL`.
263
272
  Groupdate has limited support for SQLite.
264
273
 
265
274
  - No time zone support
266
- - No `day_start` or `week_start` options
275
+ - No `day_start` option
267
276
  - No `group_by_quarter` method
268
277
 
269
278
  If your application’s time zone is set to something other than `Etc/UTC` (the default), create an initializer with:
@@ -274,36 +283,18 @@ Groupdate.time_zone = false
274
283
 
275
284
  ## Upgrading
276
285
 
277
- ### 4.0
278
-
279
- Groupdate 4.0 brings a number of improvements. Here are a few to be aware of:
286
+ ### 5.0
280
287
 
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
288
+ Groupdate 5.0 brings a number of improvements. Here are a few to be aware of:
285
289
 
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
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
300
293
 
301
294
  ## History
302
295
 
303
296
  View the [changelog](https://github.com/ankane/groupdate/blob/master/CHANGELOG.md)
304
297
 
305
- Groupdate follows [Semantic Versioning](https://semver.org/)
306
-
307
298
  ## Contributing
308
299
 
309
300
  Everyone is encouraged to help improve this project. Here are a few ways you can help:
data/lib/groupdate.rb CHANGED
@@ -1,9 +1,18 @@
1
+ # dependencies
1
2
  require "active_support/core_ext/module/attribute_accessors"
2
3
  require "active_support/time"
3
- require "groupdate/version"
4
- require "groupdate/relation_builder"
5
- require "groupdate/series_builder"
4
+
5
+ # modules
6
6
  require "groupdate/magic"
7
+ require "groupdate/series_builder"
8
+ require "groupdate/version"
9
+
10
+ # adapters
11
+ require "groupdate/adapters/base_adapter"
12
+ require "groupdate/adapters/mysql_adapter"
13
+ require "groupdate/adapters/postgresql_adapter"
14
+ require "groupdate/adapters/redshift_adapter"
15
+ require "groupdate/adapters/sqlite_adapter"
7
16
 
8
17
  module Groupdate
9
18
  class Error < RuntimeError; end
@@ -12,7 +21,7 @@ module Groupdate
12
21
  METHODS = PERIODS.map { |v| :"group_by_#{v}" } + [:group_by_period]
13
22
 
14
23
  mattr_accessor :week_start, :day_start, :time_zone, :dates
15
- self.week_start = :sun
24
+ self.week_start = :sunday
16
25
  self.day_start = 0
17
26
  self.dates = true
18
27
 
@@ -23,8 +32,23 @@ module Groupdate
23
32
  end
24
33
  result
25
34
  end
35
+
36
+ def self.adapters
37
+ @adapters ||= {}
38
+ end
39
+
40
+ def self.register_adapter(name, adapter)
41
+ Array(name).each do |n|
42
+ adapters[n] = adapter
43
+ end
44
+ end
26
45
  end
27
46
 
47
+ 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
50
+ Groupdate.register_adapter "SQLite", Groupdate::Adapters::SQLiteAdapter
51
+
28
52
  require "groupdate/enumerable"
29
53
 
30
54
  ActiveSupport.on_load(:active_record) do
@@ -0,0 +1,70 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class BaseAdapter
4
+ attr_reader :period, :column, :day_start, :week_start, :n_seconds
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
+
10
+ @relation = relation
11
+ @column = resolve_column(relation, column)
12
+ @period = period
13
+ @time_zone = time_zone
14
+ @time_range = time_range
15
+ @week_start = week_start
16
+ @day_start = day_start
17
+ @n_seconds = n_seconds
18
+
19
+ if relation.default_timezone == :local
20
+ raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
21
+ end
22
+ end
23
+
24
+ def generate
25
+ @relation.group(group_clause).where(*where_clause)
26
+ end
27
+
28
+ private
29
+
30
+ def where_clause
31
+ if @time_range.is_a?(Range)
32
+ if @time_range.end
33
+ op = @time_range.exclude_end? ? "<" : "<="
34
+ if @time_range.begin
35
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.begin, @time_range.end]
36
+ else
37
+ ["#{column} #{op} ?", @time_range.end]
38
+ end
39
+ else
40
+ ["#{column} >= ?", @time_range.begin]
41
+ end
42
+ else
43
+ ["#{column} IS NOT NULL"]
44
+ end
45
+ 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
+ end
69
+ end
70
+ end
@@ -0,0 +1,56 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class MySQLAdapter < BaseAdapter
4
+ def group_clause
5
+ time_zone = @time_zone.tzinfo.name
6
+ day_start_column = "CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL ? second"
7
+
8
+ query =
9
+ case period
10
+ when :minute_of_hour
11
+ ["MINUTE(#{day_start_column})", time_zone, day_start]
12
+ when :hour_of_day
13
+ ["HOUR(#{day_start_column})", time_zone, day_start]
14
+ when :day_of_week
15
+ ["DAYOFWEEK(#{day_start_column}) - 1", time_zone, day_start]
16
+ when :day_of_month
17
+ ["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
18
+ when :day_of_year
19
+ ["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
20
+ when :month_of_year
21
+ ["MONTH(#{day_start_column})", time_zone, day_start]
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]
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]
26
+ when :custom
27
+ ["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
28
+ else
29
+ format =
30
+ case period
31
+ when :second
32
+ "%Y-%m-%d %H:%i:%S"
33
+ when :minute
34
+ "%Y-%m-%d %H:%i:00"
35
+ when :hour
36
+ "%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
+ end
44
+
45
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
46
+ end
47
+
48
+ clean_group_clause(@relation.send(:sanitize_sql_array, query))
49
+ end
50
+
51
+ def clean_group_clause(clause)
52
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class PostgreSQLAdapter < BaseAdapter
4
+ def group_clause
5
+ time_zone = @time_zone.tzinfo.name
6
+ day_start_column = "#{column}::timestamptz AT TIME ZONE ? - 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
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]
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]
31
+ else
32
+ ["(DATE_TRUNC(?, #{day_start_column}) + INTERVAL ?) AT TIME ZONE ?", period, time_zone, day_start_interval, day_start_interval, time_zone]
33
+ end
34
+ end
35
+
36
+ clean_group_clause(@relation.send(:sanitize_sql_array, query))
37
+ end
38
+
39
+ def clean_group_clause(clause)
40
+ clause.gsub(/ (\-|\+) INTERVAL '0 second'/, "")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
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