groupdate 2.1.1 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Build Status](https://travis-ci.org/ankane/groupdate.png)](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
|