ar_aggregate_by_interval 1.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0941adcd198c82ec6bdd4d87187e6403f6738f6a
4
+ data.tar.gz: eb241530407ea9d26228d8637bdcdcfe3d07c477
5
+ SHA512:
6
+ metadata.gz: d29b854bc1a7265a68bfdee328618b7bef4cf550869536c10d065560a6ff6129bbd8b98d8f91fe4abcb254ac6942f11d84f953e807e5829c0c95577929d3f489
7
+ data.tar.gz: d5376e25140ee0d4d79d7b3614a4582ab8d80bca67a39850a648fd4e4e5e88049f81423cc6083935536baeca8777a07cfce10dc4354074d9be23cef748eb93b2
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,77 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec feature)
6
+
7
+ ## Uncomment to clear the screen before every task
8
+ # clearing :on
9
+
10
+ ## Guard internally checks for changes in the Guardfile and exits.
11
+ ## If you want Guard to automatically start up again, run guard in a
12
+ ## shell loop, e.g.:
13
+ ##
14
+ ## $ while bundle exec guard; do echo "Restarting Guard..."; done
15
+ ##
16
+ ## Note: if you are using the `directories` clause above and you are not
17
+ ## watching the project directory ('.'), the you will want to move the Guardfile
18
+ ## to a watched dir and symlink it back, e.g.
19
+ #
20
+ # $ mkdir config
21
+ # $ mv Guardfile config/
22
+ # $ ln -s config/Guardfile .
23
+ #
24
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
25
+
26
+ # Note: The cmd option is now required due to the increasing number of ways
27
+ # rspec may be run, below are examples of the most common uses.
28
+ # * bundler: 'bundle exec rspec'
29
+ # * bundler binstubs: 'bin/rspec'
30
+ # * spring: 'bin/rspec' (This will use spring if running and you have
31
+ # installed the spring binstubs per the docs)
32
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
33
+ # * 'just' rspec: 'rspec'
34
+
35
+ guard :rspec, cmd: "bundle exec rspec" do
36
+ require "guard/rspec/dsl"
37
+ dsl = Guard::RSpec::Dsl.new(self)
38
+
39
+ # Feel free to open issues for suggestions and improvements
40
+
41
+ # RSpec files
42
+ rspec = dsl.rspec
43
+ watch(rspec.spec_helper) { rspec.spec_dir }
44
+ watch(rspec.spec_support) { rspec.spec_dir }
45
+ watch(rspec.spec_files)
46
+
47
+ # Ruby files
48
+ ruby = dsl.ruby
49
+ dsl.watch_spec_files_for(ruby.lib_files)
50
+
51
+ # Rails files
52
+ rails = dsl.rails(view_extensions: %w(erb haml slim))
53
+ dsl.watch_spec_files_for(rails.app_files)
54
+ dsl.watch_spec_files_for(rails.views)
55
+
56
+ watch(rails.controllers) do |m|
57
+ [
58
+ rspec.spec.("routing/#{m[1]}_routing"),
59
+ rspec.spec.("controllers/#{m[1]}_controller"),
60
+ rspec.spec.("acceptance/#{m[1]}")
61
+ ]
62
+ end
63
+
64
+ # Rails config changes
65
+ watch(rails.spec_helper) { rspec.spec_dir }
66
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
67
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
68
+
69
+ # Capybara features specs
70
+ watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") }
71
+
72
+ # Turnip features and steps
73
+ watch(%r{^spec/acceptance/(.+)\.feature$})
74
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
75
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
76
+ end
77
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Jonathan Otto
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # ArAggregateByInterval
2
+ ---
3
+ Build arrays of counts, sums and averages from Ruby on Rails ActiveRecord models grouped by days, weeks or months. e.g.:
4
+ ```ruby
5
+ # default 'group by' is 'created_at'
6
+ Blog.count_weekly(1.month.ago).values
7
+ => [4, 2, 2, 0]
8
+ ```
9
+
10
+ ## Why?
11
+ 1. to simplify "group by" SQL queries when weeks or months are involved
12
+ 2. to fill in 0's for days/weeks/months where database has no data
13
+
14
+ ## Usage
15
+ ```ruby
16
+ ActiveRecordModel.{sum,count,avg}_{daily,weekly,monthly}(arg_hash).{values,values_and_dates}
17
+ ```
18
+
19
+ ```ruby
20
+ #values //(returns an array of numerics)
21
+ => [4, 2, 15, 0, 10]
22
+ ```
23
+ ```ruby
24
+ #values_and_dates //(returns an array of hashes)
25
+ => [{date: DateObject, value: 0}, {date: DateObject, value: 5}]
26
+ ```
27
+ ## Examples
28
+ #### Total blog posts created weekly
29
+ ```ruby
30
+ Blog.count_weekly({
31
+ group_by_column: :created_at,
32
+ from: 6.months.ago,
33
+ to: Time.zone.now
34
+ }).values
35
+ ```
36
+
37
+ #### Total calories burned per week
38
+ ```ruby
39
+ Exercise.sum_weekly({
40
+ group_by_column: :created_at,
41
+ aggregate_column: :calories,
42
+ from: 6.months.ago,
43
+ to: Time.zone.now
44
+ }).values
45
+ ```
46
+
47
+ #### Weekly revenue since beginning of year (with dates)
48
+ ```ruby
49
+ Billing.sum_weekly({
50
+ group_by_column: :transacted_at,
51
+ aggregate_column: :cents, # necessary when using sum as opposed to count
52
+ from: Time.zone.now.beginning_of_year,
53
+ to: Time.zone.now
54
+ }).values_and_dates
55
+ ```
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'bundler/gem_tasks'
2
+ begin
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
6
+ rescue LoadError
7
+ end
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'ar_aggregate_by_interval/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'ar_aggregate_by_interval'
8
+ s.version = ArAggregateByInterval::VERSION
9
+ s.authors = ['Jonathan Otto']
10
+ s.email = ['jonathan.otto@gmail.com']
11
+ s.homepage = 'https://github.com/jotto/ar_aggregate_by_interval'
12
+ s.summary = 'add [sum|count]_[daily|weekly|monthly] to your AR models for MySQL AND Postgres'
13
+ s.description = 'add [sum|count]_[daily|weekly|monthly] to your AR models for MySQL AND Postgres'
14
+
15
+ s.files = `git ls-files`.split($/)
16
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
18
+ s.require_paths = ["lib"]
19
+
20
+ s.add_development_dependency 'bundler', '~> 1.5'
21
+ s.add_development_dependency 'rake'
22
+ s.add_development_dependency 'database_cleaner', '~> 1.4'
23
+ s.add_development_dependency 'guard-rspec', '~> 4.5'
24
+ s.add_development_dependency 'sqlite3'
25
+ s.add_dependency 'activesupport', '~> 4.0'
26
+ s.add_dependency 'activerecord', '~> 4.0'
27
+ s.add_dependency 'classy_hash', '~> 0.1'
28
+ s.add_dependency 'date_iterator', '~> 1.0'
29
+
30
+ end
@@ -0,0 +1,39 @@
1
+ require 'ar_aggregate_by_interval/query_runner'
2
+ require 'ar_aggregate_by_interval/utils'
3
+
4
+ # POSTGRES AND MYSQL COMPATIBLE
5
+ # ActiveRecordModel.[sum|count]_[daily|weekly|monthly]
6
+ # examples:
7
+
8
+ module ArAggregateByInterval
9
+
10
+ def method_missing(method_name, *args)
11
+ supported_methods_rgx = /\A(count|sum|avg)_(daily|weekly|monthly)\z/
12
+
13
+ aggregate_function, interval = method_name.to_s.scan(supported_methods_rgx).flatten
14
+
15
+ return super unless aggregate_function && interval
16
+
17
+ hash_args = if args.size == 1 && args.first.is_a?(Hash)
18
+ args.first
19
+ elsif args.size > 1 && !args.any?{ |a| a.is_a?(Hash) }
20
+ Utils.args_to_hash(aggregate_function, interval, *args)
21
+ else
22
+ nil
23
+ end
24
+
25
+ return super unless hash_args
26
+
27
+ QueryRunner.new(self, {
28
+ aggregate_function: aggregate_function,
29
+ interval: interval
30
+ }.merge(hash_args))
31
+
32
+ end
33
+
34
+ end
35
+
36
+ # for queries on the class
37
+ ActiveRecord::Base.send :extend, ArAggregateByInterval
38
+ # for scoped queries
39
+ ActiveRecord::Relation.send :include, ArAggregateByInterval
@@ -0,0 +1,55 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'classy_hash'
4
+ require 'date_iterator'
5
+ require 'ar_aggregate_by_interval/utils'
6
+
7
+ module ArAggregateByInterval
8
+
9
+ class QueryResult
10
+
11
+ VALID_HASH_ARGS = {
12
+ ar_result: -> (v) { v.class.name == 'ActiveRecord::Relation' },
13
+
14
+ # hash with 1 key where the key is a date column and value is the column being aggegated
15
+ ar_result_select_col_mapping: -> (v) { v.is_a?(Hash) && v.size == 1 },
16
+
17
+ from: [Date, DateTime, Time, ActiveSupport::TimeWithZone],
18
+ to: [Date, DateTime, Time, ActiveSupport::TimeWithZone],
19
+
20
+ interval: -> (v) { Utils.ruby_strftime_map.keys.include?(v) }
21
+ }
22
+
23
+ def initialize(args)
24
+ ClassyHash.validate(args, VALID_HASH_ARGS)
25
+
26
+ @dates_values_hash = Utils.ar_to_hash(args[:ar_result], args[:ar_result_select_col_mapping])
27
+ @date_iterator_method = Utils::DATE_ITERATOR_METHOD_MAP[args[:interval]]
28
+
29
+ # strformat to match the format out of the database
30
+ @strftime_format = Utils.ruby_strftime_map[args[:interval]]
31
+
32
+ @from = args[:from]
33
+ @to = args[:to]
34
+ end
35
+
36
+ def values_and_dates
37
+ @values_and_dates ||= array_of_dates.collect do |date, formatted_date|
38
+ {
39
+ date: date,
40
+ value: Utils.to_f_or_i_or_s(@dates_values_hash[formatted_date] || 0)
41
+ }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def array_of_dates
48
+ @array_of_dates ||= @from.to_date.send(@date_iterator_method, @to.to_date).map do |date|
49
+ [date, date.strftime(@strftime_format)]
50
+ end
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,79 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'classy_hash'
4
+ require 'ar_aggregate_by_interval/utils'
5
+ require 'ar_aggregate_by_interval/query_result'
6
+
7
+ module ArAggregateByInterval
8
+
9
+ class QueryRunner
10
+
11
+ VALID_HASH_ARGS = {
12
+ aggregate_function: [String], # sum, count
13
+ interval: [String], # daily, weekly, monthly
14
+ group_by_column: [String], # i.e.: created_at
15
+
16
+ from: [Date, DateTime, Time, ActiveSupport::TimeWithZone],
17
+ to: [:optional, Date, DateTime, Time, ActiveSupport::TimeWithZone],
18
+
19
+ aggregate_column: [:optional, String, NilClass] # required when using sum (as opposed to count)
20
+ }
21
+
22
+ attr_reader :values, :values_and_dates
23
+
24
+ def initialize(ar_model, hash_args)
25
+
26
+ validate_args!(hash_args)
27
+
28
+ from = normalize_from(hash_args[:from], hash_args[:interval])
29
+ to = normalize_to(hash_args[:to] || Time.zone.try(:now) || Time.now, hash_args[:interval])
30
+
31
+ db_vendor_select_for_date_function =
32
+ Utils.select_for_grouping_column(hash_args[:group_by_column])[hash_args[:interval]]
33
+
34
+ ar_result = ar_model.
35
+ select("#{hash_args[:aggregate_function]}(#{hash_args[:aggregate_column] || '*'}) as totalchunked__").
36
+ select("#{db_vendor_select_for_date_function} as datechunk__").
37
+ group('datechunk__').
38
+ where(["#{hash_args[:group_by_column]} >= ? and #{hash_args[:group_by_column]} <= ?", from, to])
39
+
40
+ # fill the gaps of the sql results
41
+ agg_int = QueryResult.new({
42
+ ar_result: ar_result,
43
+ ar_result_select_col_mapping: {'datechunk__' => 'totalchunked__'},
44
+ from: from,
45
+ to: to,
46
+ interval: hash_args[:interval]
47
+ })
48
+
49
+ @values_and_dates = agg_int.values_and_dates
50
+ @values = @values_and_dates.collect { |hash| hash[:value] }
51
+
52
+ end
53
+
54
+ private
55
+
56
+ def validate_args!(hash_args)
57
+ ClassyHash.validate(hash_args, VALID_HASH_ARGS)
58
+ if hash_args[:aggregate_function] != 'count' && hash_args[:aggregate_column].blank?
59
+ raise RuntimeError.new('must pass the :aggregate_column arg')
60
+ end
61
+ end
62
+
63
+ # adjust "to" to end of day, week or month (if less than now)
64
+ def normalize_to(to, interval)
65
+ adjusted_to = to.send(Utils.interval_inflector(interval, 'end'))
66
+ if adjusted_to < (Time.zone.try(:now) || Time.now)
67
+ adjusted_to
68
+ else
69
+ to
70
+ end
71
+ end
72
+
73
+ # adjust "from" to beginning of day, week or month
74
+ def normalize_from(from, interval)
75
+ from.send(Utils.interval_inflector(interval, 'beginning'))
76
+ end
77
+
78
+ end
79
+ end
@@ -0,0 +1,108 @@
1
+ module ArAggregateByInterval
2
+ module Utils
3
+
4
+ extend self
5
+
6
+ DATE_ITERATOR_METHOD_MAP = {
7
+ 'monthly' => 'each_month_until',
8
+ 'weekly' => 'each_week_until',
9
+ 'daily' => 'each_day_until'
10
+ }
11
+
12
+ # support legacy arguments (as opposed to direct hash)
13
+ # can do:
14
+ # ArModel.count_weekly(:group_by_col, :from, :to, :return_dates_bool)
15
+ # ArModel.count_weekly(:from, :to, :return_dates_bool) # defaults to :created_at
16
+ # or
17
+ # ArModel.sum_weekly(:group_by_col, :aggregate_col, :from, :to, :return_dates_bool)
18
+ def args_to_hash(sum_or_count, daily_weekly_monthly, *args)
19
+ group_by_column, aggregate_column, from, to, return_dates = args
20
+
21
+ group_by_column ||= 'created_at'
22
+ return_dates ||= false
23
+
24
+ if sum_or_count == 'count'
25
+ if aggregate_column.present? && (aggregate_column.is_a?(Date) || aggregate_column.is_a?(Time))
26
+ return_dates = to
27
+ to = from
28
+ from = aggregate_column
29
+ aggregate_column = nil
30
+ end
31
+ elsif sum_or_count != 'count' && aggregate_column.nil? || !(aggregate_column.is_a?(String) || aggregate_column.is_a?(Symbol))
32
+ raise "aggregate_column cant be nil with #{sum_or_count}"
33
+ end
34
+
35
+ return {
36
+ group_by_column: group_by_column,
37
+ from: from,
38
+ to: to,
39
+ aggregate_column: aggregate_column
40
+ }.delete_if { |k, v| v.nil? }
41
+ end
42
+
43
+ def ar_to_hash(ar_result, mapping)
44
+ ar_result.to_a.inject({}) do |memo, ar_obj|
45
+ mapping.each { |key, val| memo.merge!(ar_obj.send(key) => ar_obj.send(val)) }
46
+ memo
47
+ end
48
+ end
49
+
50
+ def ruby_strftime_map
51
+ @ruby_strftime_map ||= {
52
+ 'monthly' => '%Y-%m',
53
+ # sqlite doesn't support ISO weeks
54
+ 'weekly' => Utils.db_vendor.match(/sqlite/i) ? '%Y-%U' : '%G-%V',
55
+ 'daily' => '%F'
56
+ }
57
+ end
58
+
59
+ def select_for_grouping_column(grouping_col)
60
+ case db_vendor
61
+ when /mysql/i
62
+ {
63
+ 'monthly' => "date_format(#{grouping_col}, '%Y-%m')",
64
+ 'weekly' => "date_format(#{grouping_col}, '%x-%v')",
65
+ 'daily' => "date(#{grouping_col})"
66
+ }
67
+ when /postgres/i
68
+ {
69
+ 'monthly' => "to_char(#{grouping_col}, 'YYYY-MM')",
70
+ 'weekly' => "to_char(#{grouping_col}, 'IYYY-IW')",
71
+ 'daily' => "date(#{grouping_col})"
72
+ }
73
+ when /sqlite/i
74
+ {
75
+ 'monthly' => "strftime('%Y-%m', #{grouping_col})",
76
+ 'weekly' => "strftime('%Y-%W', #{grouping_col})", # sqlite doesn't support ISO weeks
77
+ 'daily' => "date(#{grouping_col})"
78
+ }
79
+ else
80
+ raise "unknown database vendor #{db_vendor}"
81
+ end
82
+ end
83
+
84
+ def db_vendor
85
+ @db_vendor ||=
86
+ ActiveRecord::Base.connection_config.try(:symbolize_keys).try(:[], :adapter) ||
87
+ ENV['DATABASE_URL']
88
+ end
89
+
90
+ # converts args like: [weekly, beginning] to beginning_of_week
91
+ def interval_inflector(interval, beg_or_end)
92
+ raise "beginning or end, not #{beg_or_end}" unless beg_or_end.match(/\A(beginning|end)\z/)
93
+
94
+ time_interval = {
95
+ 'monthly' => 'month',
96
+ 'weekly' => 'week',
97
+ 'daily' => 'day'
98
+ }[interval] || raise("unknown interval #{interval}")
99
+
100
+ "#{beg_or_end}_of_#{time_interval}"
101
+ end
102
+
103
+ def to_f_or_i_or_s(v)
104
+ ((float = Float(v)) && (float % 1.0 == 0) ? float.to_i : float) rescue v
105
+ end
106
+
107
+ end
108
+ end
@@ -0,0 +1,3 @@
1
+ module ArAggregateByInterval
2
+ VERSION = '1.1.0'
3
+ end
@@ -0,0 +1,71 @@
1
+ require 'ar_aggregate_by_interval/query_runner'
2
+ require 'active_record'
3
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
4
+ load File.join(File.dirname(__FILE__), '../../schema.rb')
5
+
6
+ class Blog < ActiveRecord::Base; end
7
+
8
+ module ArAggregateByInterval
9
+ describe QueryRunner do
10
+
11
+ subject do
12
+ described_class.new(Blog, {
13
+ aggregate_function: aggregate_function,
14
+ aggregate_column: (aggregate_column rescue nil),
15
+ interval: interval,
16
+ group_by_column: 'created_at',
17
+ from: from,
18
+ to: to
19
+ })
20
+ end
21
+
22
+ context 'one week duration' do
23
+
24
+ # monday
25
+ let(:from) { DateTime.parse '2013-08-05' }
26
+ # sunday
27
+ let(:to) { DateTime.parse '2013-08-11' }
28
+
29
+ before do |example|
30
+ Blog.create [
31
+ {arbitrary_number: 10, created_at: from},
32
+ {arbitrary_number: 20, created_at: from}
33
+ ]
34
+ end
35
+
36
+ context 'avg daily' do
37
+ let(:interval) { 'daily' }
38
+ let(:aggregate_function) { 'avg' }
39
+ let(:aggregate_column) { 'arbitrary_number' }
40
+
41
+ describe '.values' do
42
+ it 'returns an array of size 7' do
43
+ expect(subject.values.size).to eq 7
44
+ end
45
+ it 'returns actual averages' do
46
+ expect(subject.values).to eq([15, 0, 0, 0, 0, 0, 0])
47
+ end
48
+ end
49
+ end
50
+
51
+ context 'count weekly' do
52
+ let(:interval) { 'weekly' }
53
+ let(:aggregate_function) { 'count' }
54
+
55
+ describe '.values' do
56
+ it 'returns exactly 1 element array with 1' do
57
+ expect(subject.values).to eq([2])
58
+ end
59
+ end
60
+
61
+ describe '.value_and_dates' do
62
+ it 'returns value and date with expected values' do
63
+ expect(subject.values_and_dates).to eq([date: from.beginning_of_week.to_date, value: 2])
64
+ end
65
+ end
66
+ end
67
+
68
+ end
69
+ end
70
+
71
+ end
@@ -0,0 +1,51 @@
1
+ require 'ar_aggregate_by_interval/utils'
2
+ require 'ostruct'
3
+
4
+ module ArAggregateByInterval
5
+
6
+ describe Utils do
7
+
8
+ context 'converting ar to hash' do
9
+
10
+ subject do
11
+ described_class.ar_to_hash(ar_objs, mapping)
12
+ end
13
+
14
+ context 'normal values' do
15
+ let(:ar_objs) do
16
+ [
17
+ OpenStruct.new({
18
+ date_chunk__: '2014-01-01',
19
+ value: 5
20
+ })
21
+ ]
22
+ end
23
+
24
+ let(:mapping) { { 'date_chunk__' => 'value' } }
25
+
26
+ it 'works' do
27
+ expect(subject).to eq({ '2014-01-01' => 5 })
28
+ end
29
+ end
30
+
31
+ context 'arbitrary values' do
32
+ let(:ar_objs) do
33
+ [
34
+ OpenStruct.new({
35
+ id: OpenStruct.new({}),
36
+ something: 1
37
+ })
38
+ ]
39
+ end
40
+ let(:mapping) { { 'id' => 'something' } }
41
+
42
+ it 'does not cast or change any objects' do
43
+ expect(subject.keys.first).to be_a(OpenStruct)
44
+ expect(subject.values.first).to be_a(Integer)
45
+ end
46
+ end
47
+
48
+ end
49
+ end
50
+
51
+ end
@@ -0,0 +1,125 @@
1
+ require 'active_record'
2
+ require 'ar_aggregate_by_interval'
3
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
4
+ load File.join(File.dirname(__FILE__), '../schema.rb')
5
+
6
+ class Blog < ActiveRecord::Base; end
7
+
8
+ describe ArAggregateByInterval do
9
+
10
+ before(:all) do |example|
11
+ @from = DateTime.parse '2013-08-05'
12
+ @to = @from
13
+ Blog.create arbitrary_number: 10, created_at: @from
14
+ end
15
+
16
+ shared_examples_for 'count .values_and_dates' do
17
+ it 'returns value and date with expected values' do
18
+ expect(subject.values_and_dates).to eq([date: @from.beginning_of_week.to_date, value: 1])
19
+ end
20
+ end
21
+
22
+ shared_examples_for 'sum .values_and_dates' do
23
+ it 'returns value and date with expected values' do
24
+ expect(subject.values_and_dates).to eq([date: @from.beginning_of_week.to_date, value: 10])
25
+ end
26
+ end
27
+
28
+ shared_examples_for 'avg .values_and_dates' do
29
+ it 'returns value and date with expected values' do
30
+ expect(subject.values_and_dates).to eq([date: @from.beginning_of_week.to_date, value: 10])
31
+ end
32
+ end
33
+
34
+ context 'scoped' do
35
+ subject do
36
+ Blog.where('id > 0').count_weekly('created_at', @from, @from)
37
+ end
38
+ it_behaves_like 'count .values_and_dates'
39
+ end
40
+
41
+ context 'hash args' do
42
+
43
+ context 'for count' do
44
+ subject do
45
+ Blog.count_weekly({
46
+ group_by_column: 'created_at',
47
+ from: @from,
48
+ to: @to
49
+ })
50
+ end
51
+ it_behaves_like 'count .values_and_dates'
52
+ end
53
+
54
+ context 'for sum' do
55
+ subject do
56
+ Blog.sum_weekly({
57
+ group_by_column: 'created_at',
58
+ aggregate_column: 'arbitrary_number',
59
+ from: @from,
60
+ to: @to
61
+ })
62
+ end
63
+ it_behaves_like 'sum .values_and_dates'
64
+ end
65
+
66
+ context 'for avg' do
67
+ subject do
68
+ Blog.avg_weekly({
69
+ group_by_column: 'created_at',
70
+ aggregate_column: 'arbitrary_number',
71
+ from: @from,
72
+ to: @to
73
+ })
74
+ end
75
+ it_behaves_like 'avg .values_and_dates'
76
+ end
77
+
78
+ end
79
+
80
+ context 'normal args' do
81
+ context 'for count' do
82
+ subject do
83
+ Blog.count_weekly('created_at', @from, @from)
84
+ end
85
+ it_behaves_like 'count .values_and_dates'
86
+ end
87
+ context 'for sum' do
88
+ subject do
89
+ Blog.sum_weekly('created_at', 'arbitrary_number', @from, @from)
90
+ end
91
+ it_behaves_like 'sum .values_and_dates'
92
+ end
93
+ context 'for avg' do
94
+ subject do
95
+ Blog.avg_weekly('created_at', 'arbitrary_number', @from, @from)
96
+ end
97
+ it_behaves_like 'avg .values_and_dates'
98
+ end
99
+ end
100
+
101
+ context 'bad args' do
102
+ context 'for count' do
103
+ subject do
104
+ Blog.count_weekly('created_at', {}, {})
105
+ end
106
+ it 'raise NoMethodError' do
107
+ expect do
108
+ subject
109
+ end.to raise_error(NoMethodError)
110
+ end
111
+ end
112
+
113
+ context 'for sum' do
114
+ subject do
115
+ Blog.sum_weekly('created_at', @from, @from)
116
+ end
117
+ it 'raise NoMethodError' do
118
+ expect do
119
+ subject
120
+ end.to raise_error(RuntimeError, /aggregate_column/)
121
+ end
122
+ end
123
+ end
124
+
125
+ end
data/spec/schema.rb ADDED
@@ -0,0 +1,11 @@
1
+
2
+ # ActiveRecord::Base.logger = Logger.new($stdout)
3
+ ActiveRecord::Schema.define do
4
+ self.verbose = false
5
+
6
+ create_table :blogs, :force => true do |t|
7
+ t.integer :arbitrary_number
8
+ t.timestamps null: false
9
+ end
10
+
11
+ end
@@ -0,0 +1,103 @@
1
+ require 'database_cleaner'
2
+
3
+ # This file was generated by the `rspec --init` command. Conventionally, all
4
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5
+ # The generated `.rspec` file contains `--require spec_helper` which will cause this
6
+ # file to always be loaded, without a need to explicitly require it in any files.
7
+ #
8
+ # Given that it is always loaded, you are encouraged to keep this file as
9
+ # light-weight as possible. Requiring heavyweight dependencies from this file
10
+ # will add to the boot time of your test suite on EVERY test run, even for an
11
+ # individual file that may not need all of that loaded. Instead, consider making
12
+ # a separate helper file that requires the additional dependencies and performs
13
+ # the additional setup, and require it from the spec files that actually need it.
14
+ #
15
+ # The `.rspec` file also contains a few flags that are not defaults but that
16
+ # users commonly want.
17
+ #
18
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
19
+ RSpec.configure do |config|
20
+
21
+ config.before(:suite) do
22
+ DatabaseCleaner.strategy = :transaction
23
+ # DatabaseCleaner.clean_with(:truncation)
24
+ end
25
+
26
+ config.around(:each) do |example|
27
+ DatabaseCleaner.cleaning do
28
+ example.run
29
+ end
30
+ end
31
+
32
+ # rspec-expectations config goes here. You can use an alternate
33
+ # assertion/expectation library such as wrong or the stdlib/minitest
34
+ # assertions if you prefer.
35
+ config.expect_with :rspec do |expectations|
36
+ # This option will default to `true` in RSpec 4. It makes the `description`
37
+ # and `failure_message` of custom matchers include text for helper methods
38
+ # defined using `chain`, e.g.:
39
+ # be_bigger_than(2).and_smaller_than(4).description
40
+ # # => "be bigger than 2 and smaller than 4"
41
+ # ...rather than:
42
+ # # => "be bigger than 2"
43
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
44
+ end
45
+
46
+ # rspec-mocks config goes here. You can use an alternate test double
47
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
48
+ config.mock_with :rspec do |mocks|
49
+ # Prevents you from mocking or stubbing a method that does not exist on
50
+ # a real object. This is generally recommended, and will default to
51
+ # `true` in RSpec 4.
52
+ mocks.verify_partial_doubles = true
53
+ end
54
+
55
+ # The settings below are suggested to provide a good initial experience
56
+ # with RSpec, but feel free to customize to your heart's content.
57
+ =begin
58
+ # These two settings work together to allow you to limit a spec run
59
+ # to individual examples or groups you care about by tagging them with
60
+ # `:focus` metadata. When nothing is tagged with `:focus`, all examples
61
+ # get run.
62
+ config.filter_run :focus
63
+ config.run_all_when_everything_filtered = true
64
+
65
+ # Limits the available syntax to the non-monkey patched syntax that is recommended.
66
+ # For more details, see:
67
+ # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
68
+ # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
69
+ # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching
70
+ config.disable_monkey_patching!
71
+
72
+ # This setting enables warnings. It's recommended, but in some cases may
73
+ # be too noisy due to issues in dependencies.
74
+ config.warnings = true
75
+
76
+ # Many RSpec users commonly either run the entire suite or an individual
77
+ # file, and it's useful to allow more verbose output when running an
78
+ # individual spec file.
79
+ if config.files_to_run.one?
80
+ # Use the documentation formatter for detailed output,
81
+ # unless a formatter has already been configured
82
+ # (e.g. via a command-line flag).
83
+ config.default_formatter = 'doc'
84
+ end
85
+
86
+ # Print the 10 slowest examples and example groups at the
87
+ # end of the spec run, to help surface which specs are running
88
+ # particularly slow.
89
+ config.profile_examples = 10
90
+
91
+ # Run specs in random order to surface order dependencies. If you find an
92
+ # order dependency and want to debug it, you can fix the order by providing
93
+ # the seed, which is printed after each run.
94
+ # --seed 1234
95
+ config.order = :random
96
+
97
+ # Seed global randomization in this process using the `--seed` CLI option.
98
+ # Setting this allows you to use `--seed` to deterministically reproduce
99
+ # test failures related to randomization by passing the same `--seed` value
100
+ # as the one that triggered the failure.
101
+ Kernel.srand config.seed
102
+ =end
103
+ end
metadata ADDED
@@ -0,0 +1,193 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ar_aggregate_by_interval
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Otto
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-03-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: database_cleaner
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.4'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.4'
55
+ - !ruby/object:Gem::Dependency
56
+ name: guard-rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '4.5'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '4.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: activesupport
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '4.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '4.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: activerecord
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '4.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '4.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: classy_hash
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.1'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.1'
125
+ - !ruby/object:Gem::Dependency
126
+ name: date_iterator
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.0'
139
+ description: add [sum|count]_[daily|weekly|monthly] to your AR models for MySQL AND
140
+ Postgres
141
+ email:
142
+ - jonathan.otto@gmail.com
143
+ executables: []
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".gitignore"
148
+ - ".rspec"
149
+ - Gemfile
150
+ - Guardfile
151
+ - LICENSE.txt
152
+ - README.md
153
+ - Rakefile
154
+ - ar_aggregate_counter.gemspec
155
+ - lib/ar_aggregate_by_interval.rb
156
+ - lib/ar_aggregate_by_interval/query_result.rb
157
+ - lib/ar_aggregate_by_interval/query_runner.rb
158
+ - lib/ar_aggregate_by_interval/utils.rb
159
+ - lib/ar_aggregate_by_interval/version.rb
160
+ - spec/lib/ar_aggregate_by_interval/query_runner_spec.rb
161
+ - spec/lib/ar_aggregate_by_interval/utils_spec.rb
162
+ - spec/lib/ar_aggregate_by_interval_spec.rb
163
+ - spec/schema.rb
164
+ - spec/spec_helper.rb
165
+ homepage: https://github.com/jotto/ar_aggregate_by_interval
166
+ licenses: []
167
+ metadata: {}
168
+ post_install_message:
169
+ rdoc_options: []
170
+ require_paths:
171
+ - lib
172
+ required_ruby_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ requirements: []
183
+ rubyforge_project:
184
+ rubygems_version: 2.4.6
185
+ signing_key:
186
+ specification_version: 4
187
+ summary: add [sum|count]_[daily|weekly|monthly] to your AR models for MySQL AND Postgres
188
+ test_files:
189
+ - spec/lib/ar_aggregate_by_interval/query_runner_spec.rb
190
+ - spec/lib/ar_aggregate_by_interval/utils_spec.rb
191
+ - spec/lib/ar_aggregate_by_interval_spec.rb
192
+ - spec/schema.rb
193
+ - spec/spec_helper.rb