groupdate 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +5 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +2 -0
- data/README.md +14 -2
- data/gemfiles/activerecord31.gemfile +1 -1
- data/gemfiles/activerecord32.gemfile +6 -0
- data/gemfiles/activerecord40.gemfile +6 -0
- data/groupdate.gemspec +3 -2
- data/lib/groupdate.rb +14 -22
- data/lib/groupdate/active_record.rb +44 -0
- data/lib/groupdate/enumerable.rb +9 -0
- data/lib/groupdate/magic.rb +267 -0
- data/lib/groupdate/order_hack.rb +1 -1
- data/lib/groupdate/scopes.rb +6 -76
- data/lib/groupdate/series.rb +7 -169
- data/lib/groupdate/version.rb +1 -1
- data/test/enumerable_test.rb +21 -0
- data/test/mysql_test.rb +1 -1
- data/test/postgresql_test.rb +1 -1
- data/test/test_helper.rb +20 -11
- metadata +26 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cc3e5e97ecbb9ce920165a63a9cba725b6f4c0d5
|
4
|
+
data.tar.gz: 366b811759baa6dfc4918a91428f36a1f503e35e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0245a5804aca2739f67d6b53552ba584533d237ed14ed47112a8a2995ddc3fa4e3808cc8bb304c58697886fd5a9431870f6f3a1c99e4cdfc57bf815a8b973e6b
|
7
|
+
data.tar.gz: e2c50ef5c06f46a7a311828a835b271ababe0e6589a6d1374097fa7830ab0416c875a5b49c8f539799d3e73181b6fe42e5a0a58556a14a666736abd953788db3
|
data/.travis.yml
CHANGED
@@ -3,6 +3,11 @@ rvm:
|
|
3
3
|
- 1.9.3
|
4
4
|
- 2.0.0
|
5
5
|
- jruby
|
6
|
+
gemfile:
|
7
|
+
- Gemfile
|
8
|
+
- gemfiles/activerecord31.gemfile
|
9
|
+
- gemfiles/activerecord32.gemfile
|
10
|
+
- gemfiles/activerecord40.gemfile
|
6
11
|
script: bundle exec rake test
|
7
12
|
before_script:
|
8
13
|
- mysql -e 'create database groupdate_test;'
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -11,9 +11,9 @@ The simplest way to group by:
|
|
11
11
|
|
12
12
|
:cake: Get the entire series - **the other best part**
|
13
13
|
|
14
|
-
Works with Rails 3.
|
14
|
+
Works with Rails 3.1+
|
15
15
|
|
16
|
-
Supports PostgreSQL and MySQL
|
16
|
+
Supports PostgreSQL and MySQL, plus Arrays and Hashes
|
17
17
|
|
18
18
|
[](https://travis-ci.org/ankane/groupdate)
|
19
19
|
|
@@ -137,6 +137,18 @@ User.group_by_hour_of_day(:created_at, format: "%l %P").count.keys.first # 12 am
|
|
137
137
|
|
138
138
|
Takes a `String`, which is passed to [strftime](http://strfti.me/), or a `Proc`
|
139
139
|
|
140
|
+
## Arrays and Hashes
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
users.group_by_day{|u| u.created_at } # or group_by_day(&:created_at)
|
144
|
+
```
|
145
|
+
|
146
|
+
Supports the same options as above
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
users.group_by_day(time_zone: time_zone){|u| u.created_at }
|
150
|
+
```
|
151
|
+
|
140
152
|
## Installation
|
141
153
|
|
142
154
|
Add this line to your application’s Gemfile:
|
data/groupdate.gemspec
CHANGED
@@ -18,11 +18,12 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
-
spec.add_dependency "
|
21
|
+
spec.add_dependency "activesupport", ">= 3"
|
22
22
|
|
23
23
|
spec.add_development_dependency "bundler", "~> 1.3"
|
24
24
|
spec.add_development_dependency "rake"
|
25
|
-
spec.add_development_dependency "minitest"
|
25
|
+
spec.add_development_dependency "minitest", ">= 5"
|
26
|
+
spec.add_development_dependency "activerecord"
|
26
27
|
|
27
28
|
if RUBY_PLATFORM == "java"
|
28
29
|
spec.add_development_dependency "activerecord-jdbcpostgresql-adapter"
|
data/lib/groupdate.rb
CHANGED
@@ -1,29 +1,21 @@
|
|
1
|
+
require "active_support/core_ext/module/attribute_accessors"
|
2
|
+
require "active_support/time"
|
1
3
|
require "groupdate/version"
|
2
|
-
require "groupdate/
|
3
|
-
|
4
|
-
ActiveRecord::Base.send :extend, Groupdate::Scopes
|
5
|
-
|
6
|
-
# hack for **unfixed** rails issue
|
7
|
-
# https://github.com/rails/rails/issues/7121
|
8
|
-
module ActiveRecord
|
9
|
-
module Calculations
|
10
|
-
|
11
|
-
private
|
12
|
-
|
13
|
-
def column_alias_for_with_hack(*keys)
|
14
|
-
if keys.first.is_a?(Groupdate::OrderHack)
|
15
|
-
keys.first.field
|
16
|
-
else
|
17
|
-
column_alias_for_without_hack(*keys)
|
18
|
-
end
|
19
|
-
end
|
20
|
-
alias_method_chain :column_alias_for, :hack
|
21
|
-
|
22
|
-
end
|
23
|
-
end
|
4
|
+
require "groupdate/magic"
|
24
5
|
|
25
6
|
module Groupdate
|
7
|
+
FIELDS = [:second, :minute, :hour, :day, :week, :month, :year, :day_of_week, :hour_of_day]
|
8
|
+
METHODS = FIELDS.map{|v| :"group_by_#{v}" }
|
9
|
+
|
26
10
|
mattr_accessor :week_start, :day_start, :time_zone
|
27
11
|
self.week_start = :sun
|
28
12
|
self.day_start = 0
|
29
13
|
end
|
14
|
+
|
15
|
+
require "groupdate/enumerable"
|
16
|
+
begin
|
17
|
+
require "active_record"
|
18
|
+
rescue LoadError
|
19
|
+
# do nothing
|
20
|
+
end
|
21
|
+
require "groupdate/active_record" if defined?(ActiveRecord)
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "groupdate/order_hack"
|
3
|
+
require "groupdate/scopes"
|
4
|
+
require "groupdate/series"
|
5
|
+
|
6
|
+
ActiveRecord::Base.send(:extend, Groupdate::Scopes)
|
7
|
+
|
8
|
+
module ActiveRecord
|
9
|
+
class Relation
|
10
|
+
|
11
|
+
if ActiveRecord::VERSION::MAJOR == 3 and ActiveRecord::VERSION::MINOR < 2
|
12
|
+
|
13
|
+
def method_missing_with_hack(method, *args, &block)
|
14
|
+
if Groupdate::METHODS.include?(method)
|
15
|
+
scoping { @klass.send(method, *args, &block) }
|
16
|
+
else
|
17
|
+
method_missing_without_hack(method, *args, &block)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
alias_method_chain :method_missing, :hack
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# hack for **unfixed** rails issue
|
28
|
+
# https://github.com/rails/rails/issues/7121
|
29
|
+
module ActiveRecord
|
30
|
+
module Calculations
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def column_alias_for_with_hack(*keys)
|
35
|
+
if keys.first.is_a?(Groupdate::OrderHack)
|
36
|
+
keys.first.field
|
37
|
+
else
|
38
|
+
column_alias_for_without_hack(*keys)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
alias_method_chain :column_alias_for, :hack
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,267 @@
|
|
1
|
+
module Groupdate
|
2
|
+
class Magic
|
3
|
+
attr_accessor :field, :options
|
4
|
+
|
5
|
+
def initialize(field, options)
|
6
|
+
@field = field
|
7
|
+
@options = options
|
8
|
+
|
9
|
+
if !time_zone
|
10
|
+
raise "Unrecognized time zone"
|
11
|
+
end
|
12
|
+
|
13
|
+
if field == :week and !week_start
|
14
|
+
raise "Unrecognized :week_start option"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def group_by(enum, &block)
|
19
|
+
series(enum.group_by{|v| v = yield(v); v ? round_time(v) : nil }, [])
|
20
|
+
end
|
21
|
+
|
22
|
+
def relation(column, relation)
|
23
|
+
column = relation.connection.quote_table_name(column)
|
24
|
+
time_zone = self.time_zone.tzinfo.name
|
25
|
+
|
26
|
+
adapter_name = relation.connection.adapter_name
|
27
|
+
query =
|
28
|
+
case adapter_name
|
29
|
+
when "MySQL", "Mysql2"
|
30
|
+
case field
|
31
|
+
when :day_of_week # Sunday = 0, Monday = 1, etc
|
32
|
+
# use CONCAT for consistent return type (String)
|
33
|
+
["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?)) - 1", time_zone]
|
34
|
+
when :hour_of_day
|
35
|
+
["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start}) % 24", time_zone]
|
36
|
+
when :week
|
37
|
+
["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} HOUR)) % 7) DAY) - INTERVAL #{day_start} HOUR, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} HOUR, ?, '+00:00')", time_zone, time_zone, time_zone]
|
38
|
+
else
|
39
|
+
format =
|
40
|
+
case field
|
41
|
+
when :second
|
42
|
+
"%Y-%m-%d %H:%i:%S"
|
43
|
+
when :minute
|
44
|
+
"%Y-%m-%d %H:%i:00"
|
45
|
+
when :hour
|
46
|
+
"%Y-%m-%d %H:00:00"
|
47
|
+
when :day
|
48
|
+
"%Y-%m-%d 00:00:00"
|
49
|
+
when :month
|
50
|
+
"%Y-%m-01 00:00:00"
|
51
|
+
else # year
|
52
|
+
"%Y-01-01 00:00:00"
|
53
|
+
end
|
54
|
+
|
55
|
+
["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} HOUR)", time_zone, time_zone]
|
56
|
+
end
|
57
|
+
when "PostgreSQL", "PostGIS"
|
58
|
+
case field
|
59
|
+
when :day_of_week
|
60
|
+
["EXTRACT(DOW from (#{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour'))::integer", time_zone]
|
61
|
+
when :hour_of_day
|
62
|
+
["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour')::integer", time_zone]
|
63
|
+
when :week # start on Sunday, not PostgreSQL default Monday
|
64
|
+
["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start}' hour) AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start}' hour) AT TIME ZONE ?", time_zone, time_zone]
|
65
|
+
else
|
66
|
+
["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} hour') AT TIME ZONE ?) + INTERVAL '#{day_start} hour') AT TIME ZONE ?", time_zone, time_zone]
|
67
|
+
end
|
68
|
+
else
|
69
|
+
raise "Connection adapter not supported: #{adapter_name}"
|
70
|
+
end
|
71
|
+
|
72
|
+
group = relation.group(Groupdate::OrderHack.new(relation.send(:sanitize_sql_array, query), field, time_zone))
|
73
|
+
if options[:series] == false
|
74
|
+
group
|
75
|
+
else
|
76
|
+
relation =
|
77
|
+
if time_range.is_a?(Range)
|
78
|
+
# doesn't matter whether we include the end of a ... range - it will be excluded later
|
79
|
+
group.where("#{column} >= ? AND #{column} <= ?", time_range.first, time_range.last)
|
80
|
+
else
|
81
|
+
group.where("#{column} IS NOT NULL")
|
82
|
+
end
|
83
|
+
|
84
|
+
# TODO do not change object state
|
85
|
+
@group_index = group.group_values.size - 1
|
86
|
+
|
87
|
+
Groupdate::Series.new(self, relation)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def perform(relation, method, *args, &block)
|
92
|
+
# undo reverse since we do not want this to appear in the query
|
93
|
+
reverse = relation.reverse_order_value
|
94
|
+
if reverse
|
95
|
+
relation = relation.reverse_order
|
96
|
+
end
|
97
|
+
order = relation.order_values.first
|
98
|
+
if order.is_a?(String)
|
99
|
+
parts = order.split(" ")
|
100
|
+
reverse_order = (parts.size == 2 && parts[0].to_sym == field && parts[1].to_s.downcase == "desc")
|
101
|
+
reverse = !reverse if reverse_order
|
102
|
+
end
|
103
|
+
|
104
|
+
multiple_groups = relation.group_values.size > 1
|
105
|
+
|
106
|
+
cast_method =
|
107
|
+
case field
|
108
|
+
when :day_of_week, :hour_of_day
|
109
|
+
lambda{|k| k.to_i }
|
110
|
+
else
|
111
|
+
utc = ActiveSupport::TimeZone["UTC"]
|
112
|
+
lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(time_zone) }
|
113
|
+
end
|
114
|
+
|
115
|
+
count =
|
116
|
+
begin
|
117
|
+
Hash[ relation.send(method, *args, &block).map{|k, v| [multiple_groups ? k[0...@group_index] + [cast_method.call(k[@group_index])] + k[(@group_index + 1)..-1] : cast_method.call(k), v] } ]
|
118
|
+
rescue NoMethodError
|
119
|
+
raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
|
120
|
+
end
|
121
|
+
|
122
|
+
series(count, 0, multiple_groups, reverse)
|
123
|
+
end
|
124
|
+
|
125
|
+
protected
|
126
|
+
|
127
|
+
def time_zone
|
128
|
+
@time_zone ||= begin
|
129
|
+
time_zone = options[:time_zone] || Groupdate.time_zone || Time.zone || "Etc/UTC"
|
130
|
+
time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def week_start
|
135
|
+
@week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
|
136
|
+
end
|
137
|
+
|
138
|
+
def day_start
|
139
|
+
@day_start ||= (options[:day_start] || Groupdate.day_start).to_i
|
140
|
+
end
|
141
|
+
|
142
|
+
def time_range
|
143
|
+
@time_range ||= begin
|
144
|
+
time_range = options[:range]
|
145
|
+
if !time_range and options[:last]
|
146
|
+
step = 1.send(field) if 1.respond_to?(field)
|
147
|
+
if step
|
148
|
+
now = Time.now
|
149
|
+
time_range = round_time(now - (options[:last].to_i - 1).send(field))..now
|
150
|
+
end
|
151
|
+
end
|
152
|
+
time_range
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def series(count, default_value, multiple_groups = false, reverse = false)
|
157
|
+
reverse = !reverse if options[:reverse]
|
158
|
+
|
159
|
+
series =
|
160
|
+
case field
|
161
|
+
when :day_of_week
|
162
|
+
0..6
|
163
|
+
when :hour_of_day
|
164
|
+
0..23
|
165
|
+
else
|
166
|
+
time_range = self.time_range
|
167
|
+
time_range =
|
168
|
+
if time_range.is_a?(Range)
|
169
|
+
time_range
|
170
|
+
else
|
171
|
+
# use first and last values
|
172
|
+
sorted_keys =
|
173
|
+
if multiple_groups
|
174
|
+
count.keys.map{|k| k[@group_index] }.sort
|
175
|
+
else
|
176
|
+
count.keys.sort
|
177
|
+
end
|
178
|
+
sorted_keys.first..sorted_keys.last
|
179
|
+
end
|
180
|
+
|
181
|
+
if time_range.first
|
182
|
+
series = [round_time(time_range.first)]
|
183
|
+
|
184
|
+
step = 1.send(field)
|
185
|
+
|
186
|
+
while time_range.cover?(series.last + step)
|
187
|
+
series << series.last + step
|
188
|
+
end
|
189
|
+
|
190
|
+
if multiple_groups
|
191
|
+
keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
|
192
|
+
series = series.reverse if reverse
|
193
|
+
keys.flat_map do |k|
|
194
|
+
series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
|
195
|
+
end
|
196
|
+
else
|
197
|
+
series
|
198
|
+
end
|
199
|
+
else
|
200
|
+
[]
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# reversed above if multiple groups
|
205
|
+
if !multiple_groups and reverse
|
206
|
+
series = series.to_a.reverse
|
207
|
+
end
|
208
|
+
|
209
|
+
key_format =
|
210
|
+
if options[:format]
|
211
|
+
if options[:format].respond_to?(:call)
|
212
|
+
options[:format]
|
213
|
+
else
|
214
|
+
sunday = time_zone.parse("2014-03-02 00:00:00")
|
215
|
+
lambda do |key|
|
216
|
+
case field
|
217
|
+
when :hour_of_day
|
218
|
+
key = sunday + key.hours + day_start.hours
|
219
|
+
when :day_of_week
|
220
|
+
key = sunday + key.days
|
221
|
+
end
|
222
|
+
key.strftime(options[:format].to_s)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
else
|
226
|
+
lambda{|k| k }
|
227
|
+
end
|
228
|
+
|
229
|
+
Hash[series.map do |k|
|
230
|
+
[multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), count[k] || default_value]
|
231
|
+
end]
|
232
|
+
end
|
233
|
+
|
234
|
+
def round_time(time)
|
235
|
+
time = time.to_time.in_time_zone(time_zone) - day_start.hours
|
236
|
+
|
237
|
+
time =
|
238
|
+
case field
|
239
|
+
when :second
|
240
|
+
time.change(:usec => 0)
|
241
|
+
when :minute
|
242
|
+
time.change(:sec => 0)
|
243
|
+
when :hour
|
244
|
+
time.change(:min => 0)
|
245
|
+
when :day
|
246
|
+
time.beginning_of_day
|
247
|
+
when :week
|
248
|
+
# same logic as MySQL group
|
249
|
+
weekday = (time.wday - 1) % 7
|
250
|
+
(time - ((7 - week_start + weekday) % 7).days).midnight
|
251
|
+
when :month
|
252
|
+
time.beginning_of_month
|
253
|
+
when :year
|
254
|
+
time.beginning_of_year
|
255
|
+
when :hour_of_day
|
256
|
+
time.hour
|
257
|
+
when :day_of_week
|
258
|
+
(7 - week_start + ((time.wday - 1) % 7) % 7)
|
259
|
+
else
|
260
|
+
raise "Invalid field"
|
261
|
+
end
|
262
|
+
|
263
|
+
time.is_a?(Time) ? time + day_start.hours : time
|
264
|
+
end
|
265
|
+
|
266
|
+
end
|
267
|
+
end
|
data/lib/groupdate/order_hack.rb
CHANGED
data/lib/groupdate/scopes.rb
CHANGED
@@ -1,86 +1,16 @@
|
|
1
|
-
require "groupdate/order_hack"
|
2
|
-
require "groupdate/series"
|
3
|
-
require "active_record"
|
4
|
-
|
5
1
|
module Groupdate
|
6
2
|
module Scopes
|
7
|
-
|
8
|
-
|
9
|
-
(time_fields + number_fields).each do |field|
|
3
|
+
|
4
|
+
Groupdate::FIELDS.each do |field|
|
10
5
|
define_method :"group_by_#{field}" do |*args|
|
11
6
|
args = args.dup
|
12
7
|
options = args[-1].is_a?(Hash) ? args.pop : {}
|
13
|
-
|
14
|
-
|
15
|
-
if time_zone.is_a?(ActiveSupport::TimeZone) or time_zone = ActiveSupport::TimeZone[time_zone]
|
16
|
-
time_zone_object = time_zone
|
17
|
-
time_zone = time_zone.tzinfo.name
|
18
|
-
else
|
19
|
-
raise "Unrecognized time zone"
|
20
|
-
end
|
21
|
-
|
22
|
-
# for week
|
23
|
-
week_start = [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
|
24
|
-
if field == "week" and !week_start
|
25
|
-
raise "Unrecognized :week_start option"
|
26
|
-
end
|
27
|
-
|
28
|
-
# for day
|
29
|
-
day_start = (options[:day_start] || Groupdate.day_start).to_i
|
8
|
+
options[:time_zone] ||= args[1] unless args[1].nil?
|
9
|
+
options[:range] ||= args[2] unless args[2].nil?
|
30
10
|
|
31
|
-
|
32
|
-
case connection.adapter_name
|
33
|
-
when "MySQL", "Mysql2"
|
34
|
-
case field
|
35
|
-
when "day_of_week" # Sunday = 0, Monday = 1, etc
|
36
|
-
# use CONCAT for consistent return type (String)
|
37
|
-
["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?)) - 1", time_zone]
|
38
|
-
when "hour_of_day"
|
39
|
-
["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start}) % 24", time_zone]
|
40
|
-
when "week"
|
41
|
-
["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} HOUR)) % 7) DAY) - INTERVAL #{day_start} HOUR, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} HOUR, ?, '+00:00')", time_zone, time_zone, time_zone]
|
42
|
-
else
|
43
|
-
format =
|
44
|
-
case field
|
45
|
-
when "second"
|
46
|
-
"%Y-%m-%d %H:%i:%S"
|
47
|
-
when "minute"
|
48
|
-
"%Y-%m-%d %H:%i:00"
|
49
|
-
when "hour"
|
50
|
-
"%Y-%m-%d %H:00:00"
|
51
|
-
when "day"
|
52
|
-
"%Y-%m-%d 00:00:00"
|
53
|
-
when "month"
|
54
|
-
"%Y-%m-01 00:00:00"
|
55
|
-
else # year
|
56
|
-
"%Y-01-01 00:00:00"
|
57
|
-
end
|
58
|
-
|
59
|
-
["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} HOUR), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} HOUR)", time_zone, time_zone]
|
60
|
-
end
|
61
|
-
when "PostgreSQL", "PostGIS"
|
62
|
-
case field
|
63
|
-
when "day_of_week"
|
64
|
-
["EXTRACT(DOW from (#{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour'))::integer", time_zone]
|
65
|
-
when "hour_of_day"
|
66
|
-
["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} hour')::integer", time_zone]
|
67
|
-
when "week" # start on Sunday, not PostgreSQL default Monday
|
68
|
-
["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start}' hour) AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start}' hour) AT TIME ZONE ?", time_zone, time_zone]
|
69
|
-
else
|
70
|
-
["(DATE_TRUNC('#{field}', (#{column}::timestamptz - INTERVAL '#{day_start} hour') AT TIME ZONE ?) + INTERVAL '#{day_start} hour') AT TIME ZONE ?", time_zone, time_zone]
|
71
|
-
end
|
72
|
-
else
|
73
|
-
raise "Connection adapter not supported: #{connection.adapter_name}"
|
74
|
-
end
|
75
|
-
|
76
|
-
group = group(Groupdate::OrderHack.new(sanitize_sql_array(query), field, time_zone))
|
77
|
-
range = args[2] || options[:range] || true
|
78
|
-
unless options[:series] == false
|
79
|
-
Series.new(group, field, column, time_zone_object, range, week_start, day_start, group.group_values.size - 1, options)
|
80
|
-
else
|
81
|
-
group
|
82
|
-
end
|
11
|
+
Groupdate::Magic.new(field, options).relation(args[0], self)
|
83
12
|
end
|
84
13
|
end
|
14
|
+
|
85
15
|
end
|
86
16
|
end
|
data/lib/groupdate/series.rb
CHANGED
@@ -1,189 +1,27 @@
|
|
1
1
|
module Groupdate
|
2
2
|
class Series
|
3
|
-
attr_accessor :relation
|
3
|
+
attr_accessor :magic, :relation
|
4
4
|
|
5
|
-
def initialize(
|
5
|
+
def initialize(magic, relation)
|
6
|
+
@magic = magic
|
6
7
|
@relation = relation
|
7
|
-
@field = field
|
8
|
-
@column = column
|
9
|
-
@time_zone = time_zone
|
10
|
-
@time_range = time_range
|
11
|
-
@week_start = week_start
|
12
|
-
@day_start = day_start
|
13
|
-
@group_index = group_index
|
14
|
-
@options = options
|
15
|
-
end
|
16
|
-
|
17
|
-
def perform(method, *args, &block)
|
18
|
-
utc = ActiveSupport::TimeZone["UTC"]
|
19
|
-
|
20
|
-
time_range = @time_range
|
21
|
-
if !time_range.is_a?(Range) and @options[:last]
|
22
|
-
step = 1.send(@field) if 1.respond_to?(@field)
|
23
|
-
if step
|
24
|
-
now = Time.now
|
25
|
-
time_range = round_time(now - (@options[:last].to_i - 1).send(@field))..now
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
relation =
|
30
|
-
if time_range.is_a?(Range)
|
31
|
-
# doesn't matter whether we include the end of a ... range - it will be excluded later
|
32
|
-
@relation.where("#{@column} >= ? AND #{@column} <= ?", time_range.first, time_range.last)
|
33
|
-
else
|
34
|
-
@relation.where("#{@column} IS NOT NULL")
|
35
|
-
end
|
36
|
-
|
37
|
-
# undo reverse since we do not want this to appear in the query
|
38
|
-
reverse = relation.reverse_order_value
|
39
|
-
if reverse
|
40
|
-
relation = relation.reverse_order
|
41
|
-
end
|
42
|
-
order = relation.order_values.first
|
43
|
-
if order.is_a?(String)
|
44
|
-
parts = order.split(" ")
|
45
|
-
reverse_order = (parts.size == 2 && parts[0] == @field && parts[1].to_s.downcase == "desc")
|
46
|
-
reverse = !reverse if reverse_order
|
47
|
-
end
|
48
|
-
|
49
|
-
multiple_groups = relation.group_values.size > 1
|
50
|
-
|
51
|
-
cast_method =
|
52
|
-
case @field
|
53
|
-
when "day_of_week", "hour_of_day"
|
54
|
-
lambda{|k| k.to_i }
|
55
|
-
else
|
56
|
-
lambda{|k| (k.is_a?(String) ? utc.parse(k) : k.to_time).in_time_zone(@time_zone) }
|
57
|
-
end
|
58
|
-
|
59
|
-
count =
|
60
|
-
begin
|
61
|
-
Hash[ relation.send(method, *args, &block).map{|k, v| [multiple_groups ? k[0...@group_index] + [cast_method.call(k[@group_index])] + k[(@group_index + 1)..-1] : cast_method.call(k), v] } ]
|
62
|
-
rescue NoMethodError
|
63
|
-
raise "Be sure to install time zone support - https://github.com/ankane/groupdate#for-mysql"
|
64
|
-
end
|
65
|
-
|
66
|
-
series =
|
67
|
-
case @field
|
68
|
-
when "day_of_week"
|
69
|
-
0..6
|
70
|
-
when "hour_of_day"
|
71
|
-
0..23
|
72
|
-
else
|
73
|
-
time_range =
|
74
|
-
if time_range.is_a?(Range)
|
75
|
-
time_range
|
76
|
-
else
|
77
|
-
# use first and last values
|
78
|
-
sorted_keys =
|
79
|
-
if multiple_groups
|
80
|
-
count.keys.map{|k| k[@group_index] }.sort
|
81
|
-
else
|
82
|
-
count.keys.sort
|
83
|
-
end
|
84
|
-
sorted_keys.first..sorted_keys.last
|
85
|
-
end
|
86
|
-
|
87
|
-
if time_range.first
|
88
|
-
series = [round_time(time_range.first)]
|
89
|
-
|
90
|
-
step = 1.send(@field)
|
91
|
-
|
92
|
-
while time_range.cover?(series.last + step)
|
93
|
-
series << series.last + step
|
94
|
-
end
|
95
|
-
|
96
|
-
if multiple_groups
|
97
|
-
keys = count.keys.map{|k| k[0...@group_index] + k[(@group_index + 1)..-1] }.uniq
|
98
|
-
series = series.reverse if reverse
|
99
|
-
keys.flat_map do |k|
|
100
|
-
series.map{|s| k[0...@group_index] + [s] + k[@group_index..-1] }
|
101
|
-
end
|
102
|
-
else
|
103
|
-
series
|
104
|
-
end
|
105
|
-
else
|
106
|
-
[]
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
|
-
# reversed above if multiple groups
|
111
|
-
if !multiple_groups and reverse
|
112
|
-
series = series.to_a.reverse
|
113
|
-
end
|
114
|
-
|
115
|
-
key_format =
|
116
|
-
if @options[:format]
|
117
|
-
if @options[:format].respond_to?(:call)
|
118
|
-
@options[:format]
|
119
|
-
else
|
120
|
-
sunday = @time_zone.parse("2014-03-02 00:00:00")
|
121
|
-
lambda do |key|
|
122
|
-
case @field
|
123
|
-
when "hour_of_day"
|
124
|
-
key = sunday + key.hours + @day_start.hours
|
125
|
-
when "day_of_week"
|
126
|
-
key = sunday + key.days
|
127
|
-
end
|
128
|
-
key.strftime(@options[:format].to_s)
|
129
|
-
end
|
130
|
-
end
|
131
|
-
else
|
132
|
-
lambda{|k| k }
|
133
|
-
end
|
134
|
-
|
135
|
-
Hash[series.map do |k|
|
136
|
-
[multiple_groups ? k[0...@group_index] + [key_format.call(k[@group_index])] + k[(@group_index + 1)..-1] : key_format.call(k), count[k] || 0]
|
137
|
-
end]
|
138
|
-
end
|
139
|
-
|
140
|
-
def round_time(time)
|
141
|
-
time = time.to_time.in_time_zone(@time_zone) - @day_start.hours
|
142
|
-
|
143
|
-
time =
|
144
|
-
case @field
|
145
|
-
when "second"
|
146
|
-
time.change(:usec => 0)
|
147
|
-
when "minute"
|
148
|
-
time.change(:sec => 0)
|
149
|
-
when "hour"
|
150
|
-
time.change(:min => 0)
|
151
|
-
when "day"
|
152
|
-
time.beginning_of_day
|
153
|
-
when "week"
|
154
|
-
# same logic as MySQL group
|
155
|
-
weekday = (time.wday - 1) % 7
|
156
|
-
(time - ((7 - @week_start + weekday) % 7).days).midnight
|
157
|
-
when "month"
|
158
|
-
time.beginning_of_month
|
159
|
-
else # year
|
160
|
-
time.beginning_of_year
|
161
|
-
end
|
162
|
-
|
163
|
-
time + @day_start.hours
|
164
|
-
end
|
165
|
-
|
166
|
-
def clone
|
167
|
-
Groupdate::Series.new(@relation, @field, @column, @time_zone, @time_range, @week_start, @day_start, @group_index, @options)
|
168
8
|
end
|
169
9
|
|
170
10
|
# clone to prevent modifying original variables
|
171
11
|
def method_missing(method, *args, &block)
|
172
12
|
# https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation/calculations.rb
|
173
13
|
if ActiveRecord::Calculations.method_defined?(method)
|
174
|
-
|
14
|
+
magic.perform(relation, method, *args, &block)
|
175
15
|
elsif @relation.respond_to?(method)
|
176
|
-
|
177
|
-
series.relation = @relation.send(method, *args, &block)
|
178
|
-
series
|
16
|
+
Groupdate::Series.new(magic, relation.send(method, *args, &block))
|
179
17
|
else
|
180
18
|
super
|
181
19
|
end
|
182
20
|
end
|
183
21
|
|
184
22
|
def respond_to?(method, include_all = false)
|
185
|
-
ActiveRecord::Calculations.method_defined?(method) ||
|
23
|
+
ActiveRecord::Calculations.method_defined?(method) || relation.respond_to?(method) || super
|
186
24
|
end
|
187
25
|
|
188
|
-
end
|
26
|
+
end
|
189
27
|
end
|
data/lib/groupdate/version.rb
CHANGED
@@ -0,0 +1,21 @@
|
|
1
|
+
require_relative "test_helper"
|
2
|
+
|
3
|
+
class TestEnumerable < Minitest::Test
|
4
|
+
include TestGroupdate
|
5
|
+
|
6
|
+
def test_enumerable
|
7
|
+
user_a = User.new(created_at: utc.parse("2014-01-21"))
|
8
|
+
user_b = User.new(created_at: utc.parse("2014-03-14"))
|
9
|
+
expected = {
|
10
|
+
utc.parse("2014-01-01") => [user_a],
|
11
|
+
utc.parse("2014-02-01") => [],
|
12
|
+
utc.parse("2014-03-01") => [user_b]
|
13
|
+
}
|
14
|
+
assert_equal expected, [user_a, user_b].group_by_month(&:created_at)
|
15
|
+
end
|
16
|
+
|
17
|
+
def call_method(method, field, options)
|
18
|
+
Hash[ User.all.to_a.send(:"group_by_#{method}", options){|u| u.send(field) }.map{|k, v| [k, v.size] } ]
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
data/test/mysql_test.rb
CHANGED
data/test/postgresql_test.rb
CHANGED
data/test/test_helper.rb
CHANGED
@@ -3,6 +3,7 @@ Bundler.require(:default)
|
|
3
3
|
require "minitest/autorun"
|
4
4
|
require "minitest/pride"
|
5
5
|
require "logger"
|
6
|
+
require "active_record"
|
6
7
|
|
7
8
|
# TODO determine why this is necessary
|
8
9
|
if RUBY_PLATFORM == "java"
|
@@ -26,7 +27,7 @@ end
|
|
26
27
|
ActiveRecord::Migration.create_table :users, :force => true do |t|
|
27
28
|
t.string :name
|
28
29
|
t.integer :score
|
29
|
-
t.
|
30
|
+
t.timestamp :created_at
|
30
31
|
end
|
31
32
|
end
|
32
33
|
|
@@ -386,7 +387,7 @@ module TestGroupdate
|
|
386
387
|
7.times do |n|
|
387
388
|
expected[n] = n == 3 ? 1 : 0
|
388
389
|
end
|
389
|
-
assert_equal expected,
|
390
|
+
assert_equal expected, call_method(:day_of_week, :created_at, {})
|
390
391
|
end
|
391
392
|
|
392
393
|
def test_zeros_hour_of_day
|
@@ -395,7 +396,7 @@ module TestGroupdate
|
|
395
396
|
24.times do |n|
|
396
397
|
expected[n] = n == 20 ? 1 : 0
|
397
398
|
end
|
398
|
-
assert_equal expected,
|
399
|
+
assert_equal expected, call_method(:hour_of_day, :created_at, {})
|
399
400
|
end
|
400
401
|
|
401
402
|
def test_zeros_excludes_end
|
@@ -403,7 +404,7 @@ module TestGroupdate
|
|
403
404
|
expected = {
|
404
405
|
utc.parse("2013-05-01 00:00:00 UTC") => 0
|
405
406
|
}
|
406
|
-
assert_equal expected,
|
407
|
+
assert_equal expected, call_method(:day, :created_at, range: Time.parse("2013-05-01 00:00:00 UTC")...Time.parse("2013-05-02 00:00:00 UTC"))
|
407
408
|
end
|
408
409
|
|
409
410
|
def test_zeros_previous_scope
|
@@ -419,13 +420,13 @@ module TestGroupdate
|
|
419
420
|
expected = {
|
420
421
|
utc.parse("2013-05-01 00:00:00 UTC") => 1
|
421
422
|
}
|
422
|
-
assert_equal expected,
|
423
|
+
assert_equal expected, call_method(:day, :created_at, range: DateTime.parse("2013-05-01 00:00:00 UTC")..DateTime.parse("2013-05-01 00:00:00 UTC"))
|
423
424
|
end
|
424
425
|
|
425
426
|
def test_zeros_null_value
|
426
427
|
user = User.create!(name: "Andrew")
|
427
428
|
user.update_column :created_at, nil
|
428
|
-
assert_equal 0,
|
429
|
+
assert_equal 0, call_method(:hour_of_day, :created_at, range: true)[0]
|
429
430
|
end
|
430
431
|
|
431
432
|
def test_zeroes_range_true
|
@@ -436,7 +437,7 @@ module TestGroupdate
|
|
436
437
|
utc.parse("2013-05-02 00:00:00 UTC") => 0,
|
437
438
|
utc.parse("2013-05-03 00:00:00 UTC") => 1
|
438
439
|
}
|
439
|
-
assert_equal expected,
|
440
|
+
assert_equal expected, call_method(:day, :created_at, range: true)
|
440
441
|
end
|
441
442
|
|
442
443
|
# week_start
|
@@ -469,6 +470,10 @@ module TestGroupdate
|
|
469
470
|
assert_equal 0, User.group_by_hour_of_day(:created_at).order("hour_of_day desc").reverse_order.count.keys.first
|
470
471
|
end
|
471
472
|
|
473
|
+
def test_order_hour_of_day_reverse_option
|
474
|
+
assert_equal 23, call_method(:hour_of_day, :created_at, reverse: true).keys.first
|
475
|
+
end
|
476
|
+
|
472
477
|
def test_table_name
|
473
478
|
assert_empty User.group_by_day("users.created_at").count
|
474
479
|
end
|
@@ -481,7 +486,7 @@ module TestGroupdate
|
|
481
486
|
def test_time_zone
|
482
487
|
create_user "2013-05-01 00:00:00 UTC"
|
483
488
|
time_zone = "Pacific Time (US & Canada)"
|
484
|
-
assert_equal time_zone,
|
489
|
+
assert_equal time_zone, call_method(:day, :created_at, time_zone: time_zone).keys.first.time_zone.name
|
485
490
|
end
|
486
491
|
|
487
492
|
def test_where_after
|
@@ -604,7 +609,7 @@ module TestGroupdate
|
|
604
609
|
# helpers
|
605
610
|
|
606
611
|
def assert_format(method, expected, format, options = {})
|
607
|
-
assert_equal expected,
|
612
|
+
assert_equal expected, call_method(method, :created_at, options.merge(format: format)).keys.first
|
608
613
|
end
|
609
614
|
|
610
615
|
def assert_result_time(method, expected, time_str, time_zone = false, options = {})
|
@@ -618,7 +623,7 @@ module TestGroupdate
|
|
618
623
|
|
619
624
|
def result(method, time_str, time_zone = false, options = {})
|
620
625
|
create_user time_str
|
621
|
-
|
626
|
+
call_method(method, :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil))
|
622
627
|
end
|
623
628
|
|
624
629
|
def assert_zeros(method, created_at, keys, range_start, range_end, time_zone = nil, options = {})
|
@@ -627,7 +632,11 @@ module TestGroupdate
|
|
627
632
|
keys.each_with_index do |key, i|
|
628
633
|
expected[utc.parse(key).in_time_zone(time_zone ? "Pacific Time (US & Canada)" : utc)] = i == 1 ? 1 : 0
|
629
634
|
end
|
630
|
-
assert_equal expected,
|
635
|
+
assert_equal expected, call_method(method, :created_at, options.merge(time_zone: time_zone ? "Pacific Time (US & Canada)" : nil, range: Time.parse(range_start)..Time.parse(range_end)))
|
636
|
+
end
|
637
|
+
|
638
|
+
def call_method(method, field, options)
|
639
|
+
User.send(:"group_by_#{method}", field, options).count
|
631
640
|
end
|
632
641
|
|
633
642
|
def create_user(created_at, score = 1)
|
metadata
CHANGED
@@ -1,29 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: groupdate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.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: 2014-
|
11
|
+
date: 2014-06-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activesupport
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 3
|
19
|
+
version: '3'
|
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: 3
|
26
|
+
version: '3'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: bundler
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -54,6 +54,20 @@ dependencies:
|
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '5'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '5'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: activerecord
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
58
72
|
requirements:
|
59
73
|
- - ">="
|
@@ -109,12 +123,18 @@ files:
|
|
109
123
|
- README.md
|
110
124
|
- Rakefile
|
111
125
|
- gemfiles/activerecord31.gemfile
|
126
|
+
- gemfiles/activerecord32.gemfile
|
127
|
+
- gemfiles/activerecord40.gemfile
|
112
128
|
- groupdate.gemspec
|
113
129
|
- lib/groupdate.rb
|
130
|
+
- lib/groupdate/active_record.rb
|
131
|
+
- lib/groupdate/enumerable.rb
|
132
|
+
- lib/groupdate/magic.rb
|
114
133
|
- lib/groupdate/order_hack.rb
|
115
134
|
- lib/groupdate/scopes.rb
|
116
135
|
- lib/groupdate/series.rb
|
117
136
|
- lib/groupdate/version.rb
|
137
|
+
- test/enumerable_test.rb
|
118
138
|
- test/mysql_test.rb
|
119
139
|
- test/postgresql_test.rb
|
120
140
|
- test/test_helper.rb
|
@@ -143,6 +163,7 @@ signing_key:
|
|
143
163
|
specification_version: 4
|
144
164
|
summary: The simplest way to group temporal data
|
145
165
|
test_files:
|
166
|
+
- test/enumerable_test.rb
|
146
167
|
- test/mysql_test.rb
|
147
168
|
- test/postgresql_test.rb
|
148
169
|
- test/test_helper.rb
|