event-counter 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5c824074d24fd8d6d102c55c7cd81c4026e3cdd0
4
+ data.tar.gz: 906bf98133f1f082d6a3f1c1d4831c64f7974ea3
5
+ SHA512:
6
+ metadata.gz: 8db9c66d38029ccf99b8fa828f5936a1dc8ea84412822d7fc1037b4a2d4979988f429e4b2a96379d23e821b4ad08e792409f617b7ac1eb28f71f429341a608fc
7
+ data.tar.gz: a4085a8f4901cd3854b50ff56791c17bcd345d4269845e4ab5523a3bc35c65ced9bd88e6702ac2c0e2c77f8882edc73afdc5a244fa5f8a2bb927866b0f4c1678
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /config/database.yml
6
+ /coverage/
7
+ /doc/
8
+ /log
9
+ /pkg/
10
+ /spec/reports/
11
+ /tmp/
12
+ *.bundle
13
+ *.so
14
+ *.o
15
+ *.a
16
+ *.gem
17
+ mkmf.log
data/.rubocop.yml ADDED
@@ -0,0 +1,3 @@
1
+ Style/Blocks:
2
+ Exclude:
3
+ - 'spec/**/*'
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Anton Orel
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,120 @@
1
+ EventCounter
2
+ ===============
3
+
4
+ EventCounter is a database based event counter with throttling per time intervals.
5
+
6
+ Usage
7
+ -----
8
+
9
+ Let's define counters in model
10
+
11
+ ```ruby
12
+ class Article < ActiveRecord::Base
13
+ has_counter :views, interval: 5.minutes
14
+
15
+ # :year, :month, :week and :day symbols are supported
16
+ has_counter :views_by_week, interval: :week
17
+ end
18
+ ```
19
+
20
+ Let's count...
21
+
22
+ ```ruby
23
+ article = Article.create!
24
+
25
+ article.up!(:views)
26
+ # creates counter (if it doesn't exist) with value 1 and on Time.now() rounded to 5 minutes, e.x.:
27
+ #=> #<EventCounter id: 1, name: "views", value: 1, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:20:00">
28
+
29
+ # at once
30
+ article.up!(:views, 3)
31
+ #=> #<EventCounter id: 1, name: "views", value: 4, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:20:00">
32
+ # it will update counter (if the other exists in that interval) with value 3 and on Time.now() rounded to 5 minutes
33
+
34
+ # later
35
+ article.up!(:views, 5)
36
+ #=> #<EventCounter id: 2, name: "views", value: 5, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:25:00">
37
+ article.down!(:views, 2)
38
+ #=> #<EventCounter id: 2, name: "views", value: 3, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:25:00">
39
+
40
+ # anytime or in a background job
41
+ article.up!(:views, 7, on_time: 10.minutes.ago)
42
+ #=> #<EventCounter id: 3, name: "views", value: 7, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:15:00">
43
+
44
+ # we have not got? let's fix it
45
+ article.up!(:views, 9, on_time: 10.minutes.ago, force: true)
46
+ #=> #<EventCounter id: 3, name: "views", value: 9, countable_id: 1, countable_type: "Article", created_at: "2014-10-16 23:15:00">
47
+ ```
48
+
49
+ Let's get some statistics for our charts...
50
+
51
+ ```ruby
52
+ article.data_for(:views)
53
+ #=> [[2014-10-16 23:15:00 +0400, 9], [2014-10-16 23:20:00 +0400, 4], [2014-10-16 23:25:00 +0400, 3]]
54
+
55
+ article.data_for(:views, interval: 10.minutes)
56
+ #=> [[2014-10-16 23:10:00 +0400, 9], [2014-10-16 23:20:00 +0400, 7]]
57
+
58
+ range = Time.mktime(2014, 10, 16, 23, 0)..Time.mktime(2014, 10, 16, 23, 10)
59
+ article.data_for(:views, interval: 10.minutes, range: range)
60
+ #=> [[2014-10-16 23:00:00 +0400, 0], [2014-10-16 23:10:00 +0400, 9]]
61
+
62
+ article.data_for(:views, interval: :day)
63
+ #=> [[2014-10-16 00:00:00 +0400, 16]]
64
+
65
+ article.data_for(:views, interval: :day, raw: true)
66
+ #=> [{"created_at" => "2014-10-16 00:00:00+04", "value" => "16"}]
67
+ # raw result will make difference in performance on a big data
68
+
69
+ # class wide
70
+ range = Time.mktime(2014, 10, 15)..Time.mktime(2014, 10, 16)
71
+ Article.data_for(:views, interval: :day, range: range)
72
+ #=> [[2014-10-15 00:00:00 +0400, 0], [2014-10-16 00:00:00 +0400, 16]]
73
+ ```
74
+
75
+ Limitations
76
+ -----------
77
+
78
+ - It works *ONLY* with *PostgreSQL* at the moment.
79
+ - ActiveRecord 3+
80
+ - It's polymorphic association.
81
+ - Use it in production with caution because it's early release.
82
+
83
+
84
+ Installation
85
+ --------------
86
+
87
+ Add gem to Gemfile
88
+
89
+ ```ruby
90
+ gem 'event_counter'
91
+ ```
92
+
93
+ Create migration `rails g migration create_event_counters` with the
94
+ following code:
95
+
96
+ ```ruby
97
+ class CreateEventCounters < ActiveRecord::Migration
98
+
99
+ def change
100
+ create_table :event_counters, force: true do |t|
101
+ t.string :name, null: false
102
+ t.integer :value, default: 0, null: false
103
+ t.references :countable, polymorphic: true, null: false
104
+
105
+ t.datetime :created_at
106
+ end
107
+
108
+ add_index :event_counters, :countable_type
109
+ add_index :event_counters,
110
+ [:countable_type, :name, :countable_id], name: 'complex_index'
111
+
112
+ end
113
+
114
+ end
115
+ ```
116
+
117
+ License
118
+ ----
119
+
120
+ MIT
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,5 @@
1
+ postgresql:
2
+ adapter: postgresql
3
+ database: event_counter_test
4
+ username: postgres
5
+ min_messages: WARNING
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'event_counter/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'event-counter'
8
+ spec.version = EventCounterVersion::VERSION
9
+ spec.authors = ['Anton Orel']
10
+ spec.email = ['eagle.anton@gmail.com']
11
+ spec.summary = 'Event counter with throttling per time interval'
12
+ spec.description = 'Database based event counter with throttling per time intervals'
13
+ spec.homepage = 'https://github.com/skyeagle/event-counter'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(/^(test|spec|features)\//)
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_dependency 'activerecord', '~> 3'
22
+ spec.add_dependency 'pg', '~> 0'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.7'
25
+ spec.add_development_dependency 'database_cleaner', '~> 0'
26
+ spec.add_development_dependency 'rspec', '~> 3'
27
+ spec.add_development_dependency 'fabrication', '~> 0'
28
+ spec.add_development_dependency 'rake', '~> 10.0'
29
+ end
@@ -0,0 +1 @@
1
+ require 'event_counter'
@@ -0,0 +1,224 @@
1
+ class EventCounter < ActiveRecord::Base
2
+ # This module adds functionality to ActiveRecord
3
+ module ActiveRecordExtension
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :event_counters
8
+ self.event_counters = {}
9
+ end
10
+
11
+ # :nodoc:
12
+ module ClassMethods
13
+ def event_counter_for(name, interval)
14
+ event_counters[name] = interval
15
+
16
+ clause = { name: name.to_s }
17
+
18
+ if ActiveRecord::VERSION::MAJOR > 3
19
+ has_many name,
20
+ -> { where(clause) }, as: :countable, class_name: 'EventCounter'
21
+ else
22
+ has_many name,
23
+ conditions: clause, as: :countable, class_name: 'EventCounter'
24
+ end
25
+ include CountableInstanceMethods
26
+ extend CountableClassMethods
27
+ end
28
+ end
29
+
30
+ # This module defines instance methods for a countable model
31
+ module CountableInstanceMethods
32
+
33
+ def up!(*args)
34
+ opts = args.extract_options!
35
+ opts.merge!(vector: :up)
36
+ rotate_counter(*args, opts)
37
+ end
38
+
39
+ def down!(*args)
40
+ opts = args.extract_options!
41
+ opts.merge!(vector: :down)
42
+ rotate_counter(*args, opts)
43
+ end
44
+
45
+ def data_for(name, interval: nil, range: nil, raw: false)
46
+ self.class.data_for(name, id, interval: interval, range: range, raw: raw)
47
+ end
48
+
49
+ private
50
+
51
+ def rotate_counter(*args)
52
+ opts = args.extract_options!
53
+ name, val = args
54
+ unless respond_to?(name)
55
+ self.class.counter_error!(:not_found, name: name)
56
+ end
57
+ send(name).change(val, opts)
58
+ end
59
+ end
60
+
61
+ # This module defines class methods for a countable model
62
+ module CountableClassMethods
63
+
64
+ INTERVALS = {
65
+ year: 1.year,
66
+ month: 1.month,
67
+ week: 1.week,
68
+ day: 1.day
69
+ }.freeze
70
+
71
+ def data_for(counter_name, id = nil, interval: nil, range: nil, raw: false)
72
+ interval = normalize_interval!(counter_name, interval)
73
+
74
+ sql = <<SQL.squish!
75
+ #{cte_definition(counter_name, interval, id)}
76
+ SELECT
77
+ created_at,
78
+ COALESCE(sum(value) OVER (PARTITION BY counters.created_at) , 0) AS value
79
+ FROM (#{series_definition(interval, range)}) intervals
80
+ LEFT JOIN CTE counters USING (created_at)
81
+ ORDER BY 1
82
+ SQL
83
+
84
+ result = connection.execute(sql).to_a
85
+
86
+ raw ? result : normalize_counters_data(result)
87
+ end
88
+
89
+ def cte_definition(counter_name, interval, id = nil)
90
+ <<SQL
91
+ WITH CTE AS (
92
+ SELECT #{cte_extract(interval)} as created_at, sum(value) AS value
93
+ FROM event_counters
94
+ WHERE
95
+ countable_type = #{sanitize(name)} AND
96
+ #{ "countable_id = #{sanitize(id)} AND" if id.present? }
97
+ name = #{sanitize(counter_name)}
98
+ GROUP BY 1
99
+ )
100
+ SQL
101
+ end
102
+
103
+ def cte_extract(interval)
104
+ case interval
105
+ when Symbol
106
+ "date_trunc(#{sanitize(interval)}, created_at)"
107
+ else
108
+ tstamp(<<SQL)
109
+ floor(EXTRACT(EPOCH FROM created_at::timestamp with time zone) /
110
+ #{sanitize(interval)})::int * #{sanitize(interval)}
111
+ SQL
112
+ end
113
+ end
114
+
115
+ def series_definition(interval, range)
116
+ range_min, range_max = min_and_max_of_range(interval, range)
117
+
118
+ args =
119
+ case interval
120
+ when Symbol
121
+ interval_sql = "interval '1 #{interval}'"
122
+ if range
123
+ [
124
+ "date_trunc(#{sanitize(interval)}, #{tstamp(range.min.to_i)} )",
125
+ "date_trunc(#{sanitize(interval)}, #{tstamp(range.max.to_i)} )",
126
+ interval_sql
127
+ ]
128
+ else
129
+ [
130
+ "date_trunc(#{sanitize(interval)}, min(created_at))",
131
+ "date_trunc(#{sanitize(interval)}, max(created_at))",
132
+ interval_sql
133
+ ]
134
+ end
135
+ else
136
+ interval_sql = %Q(#{sanitize(interval)} * interval '1 seconds')
137
+ if range
138
+ [
139
+ tstamp(sanitize(range_min)),
140
+ tstamp(sanitize(range_max)),
141
+ interval_sql
142
+ ]
143
+ else
144
+ [ 'min(created_at)', 'max(created_at)', interval_sql ]
145
+ end
146
+ end
147
+ <<SQL
148
+ SELECT
149
+ count(*), generate_series(#{args[0]}, #{args[1] }, #{args[2]}) AS created_at
150
+ FROM CTE
151
+ SQL
152
+ end
153
+
154
+ def tstamp(val)
155
+ "to_timestamp(#{val})"
156
+ end
157
+
158
+ def counter_error!(*args)
159
+ fail EventCounter::CounterError, args
160
+ end
161
+
162
+ def normalize_interval!(counter_name, interval)
163
+ default_interval = default_interval_for(counter_name)
164
+
165
+ h = {
166
+ default_interval: default_interval,
167
+ interval: interval,
168
+ model: self.class.name
169
+ }
170
+
171
+ return default_interval.to_i unless interval
172
+
173
+ counter_error!(:not_found, name: name) unless default_interval
174
+ counter_error!(:less, h) if less_then_default?(default_interval, interval)
175
+ unless multiple_of_default?(default_interval, interval)
176
+ counter_error!(:multiple, h)
177
+ end
178
+
179
+ interval.respond_to?(:to_i) ? interval.to_i : interval
180
+ end
181
+
182
+ def less_then_default?(*args)
183
+ default, provided = args.map do |arg|
184
+ interval_as_integer(arg)
185
+ end
186
+ provided < default
187
+ end
188
+
189
+ def multiple_of_default?(default_interval, provided)
190
+ return true if provided.is_a?(Symbol)
191
+ provided.modulo(default_interval).zero?
192
+ end
193
+
194
+ def interval_as_integer(interval)
195
+ interval.is_a?(Symbol) ? INTERVALS[interval] : interval
196
+ end
197
+
198
+ def normalize_counters_data(raw_data)
199
+ raw_data.map do |i|
200
+ [ Time.parse(i['created_at']), i['value'].to_i ]
201
+ end
202
+ end
203
+
204
+ def default_interval_for(counter_name)
205
+ event_counters[counter_name.to_sym]
206
+ end
207
+
208
+ def min_and_max_of_range(interval, range)
209
+ return unless range
210
+
211
+ case interval
212
+ when Symbol
213
+ [
214
+ range.min.send(:"beginning_of_#{interval}").to_i,
215
+ range.max.send(:"end_of_#{interval}").to_i
216
+ ]
217
+ else
218
+ [ range.min.floor(interval).to_i, range.max.floor(interval).to_i ]
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ end
@@ -0,0 +1,4 @@
1
+ # This module contains VERSION
2
+ module EventCounterVersion
3
+ VERSION = '0.0.1'
4
+ end
@@ -0,0 +1,148 @@
1
+ require 'event_counter/version'
2
+
3
+ # This class defines model that stores all counters.
4
+ class EventCounter < ActiveRecord::Base
5
+ include EventCounterVersion
6
+
7
+ belongs_to :countable, polymorphic: true
8
+
9
+ def increase_by(val)
10
+ self.class.where(id: id).update_all(['value = value + ?', val])
11
+ increment(:value, val)
12
+ self
13
+ end
14
+
15
+ def decrease_by(decrement)
16
+ self.class.where(id: id).update_all(['value = value - ?', decrement])
17
+ decrement(:value, decrement)
18
+ self
19
+ end
20
+
21
+ def reset_value(val = 0)
22
+ self.class.where(id: id).update_all(['value = ?', val])
23
+ self.value = val
24
+ self
25
+ end
26
+
27
+ def self.make(val = 1, on_time: nil, force: false)
28
+ on_time = normalize_on_time(on_time)
29
+
30
+ attrs = { created_at: on_time }
31
+
32
+ if force && (found = scoped_relatiion.where(attrs).first)
33
+ found.reset_value(val)
34
+ else
35
+ attrs.merge!(value: val)
36
+ scoped_relatiion.create!(attrs)
37
+ end
38
+ end
39
+
40
+ def self.current_interval
41
+ scoped_relatiion.proxy_association.owner.event_counters[counter_name]
42
+ end
43
+
44
+ def self.counter_name
45
+ scoped_relatiion.proxy_association.reflection.name
46
+ end
47
+
48
+ def self.change(val = 1, vector: :up, on_time: nil, force: nil)
49
+ counter_error!(:direction) unless [:up, :down].include?(vector)
50
+
51
+ val ||= 1
52
+
53
+ on_time = normalize_on_time(on_time)
54
+
55
+ counter = where(created_at: on_time).first
56
+
57
+ return counter.update!(vector, val, force) if counter
58
+
59
+ val = -val if vector == :down
60
+ make(val, on_time: on_time, force: force)
61
+ end
62
+
63
+ def update!(vector, val = 1, force = false)
64
+ if force
65
+ val = -val if vector == :down
66
+ reset_value(val)
67
+ else
68
+ vector == :up ? increase_by(val) : decrease_by(val)
69
+ end
70
+ end
71
+
72
+ def self.scoped_relatiion
73
+ ActiveRecord::VERSION::MAJOR > 3 ? where(nil) : scoped
74
+ end
75
+
76
+ def self.up!(*args)
77
+ change(:up, *args)
78
+ end
79
+
80
+ def self.down!(*args)
81
+ change(:down, *args)
82
+ end
83
+
84
+ def self.counter_error!(*args)
85
+ fail CounterError, args
86
+ end
87
+
88
+ def self.normalize_on_time(on_time)
89
+ on_time ||= Time.now
90
+ on_time =
91
+ case current_interval
92
+ when Symbol
93
+ on_time.send(:"beginning_of_#{current_interval}")
94
+ else
95
+ on_time.floor(current_interval)
96
+ end
97
+ on_time
98
+ end
99
+
100
+ # Default error class
101
+ class CounterError < StandardError
102
+ MESSAGES = {
103
+ not_found: 'Unable to find counter (%{name}).',
104
+ direction: 'Wrong direction for counter.' \
105
+ 'Possible values are :up and :down as symbols.',
106
+ less: 'Specified interval (%{interval}) could not be less then ' \
107
+ 'a defined (%{default_interval}) in a countable model (%{model}).',
108
+ multiple: 'Specified interval (%{interval}) should be a multiple of ' \
109
+ 'a defined (%{default_interval}) in a countable model (%{model}).'
110
+ }
111
+
112
+ attr_accessor :extra
113
+
114
+ def initialize(*args)
115
+ @msg, self.extra = args.flatten!
116
+ super(@msg)
117
+ end
118
+
119
+ def to_s
120
+ @msg.is_a?(Symbol) ? MESSAGES[@msg] % extra : super
121
+ end
122
+ end
123
+ end
124
+
125
+ require 'event_counter/active_record_extension'
126
+
127
+ ActiveRecord::Base.send(:include, EventCounter::ActiveRecordExtension)
128
+
129
+ # :nodoc:
130
+ class Time
131
+ def floor(seconds = 60)
132
+ Time.at((to_f / seconds).floor * seconds)
133
+ end
134
+ end
135
+
136
+ class String
137
+
138
+ unless method_defined?(:squish!)
139
+ # Stolen from Rails
140
+ def squish!
141
+ gsub!(/\A[[:space:]]+/, '')
142
+ gsub!(/[[:space:]]+\z/, '')
143
+ gsub!(/[[:space:]]+/, ' ')
144
+ self
145
+ end
146
+ end
147
+
148
+ end
@@ -0,0 +1,346 @@
1
+ require 'spec_helper'
2
+
3
+ describe EventCounter do
4
+ let(:ball) { Fabricate(:ball) }
5
+
6
+ it 'has version' do
7
+ expect(EventCounter::VERSION).to match(/\d+\.\d+\.\d+/)
8
+ end
9
+
10
+ it '#make' do
11
+ expect {
12
+ counter = ball.rotations.make(3)
13
+ expect(counter).to be_a(EventCounter)
14
+
15
+ expected = {
16
+ countable_id: ball.id,
17
+ countable_type: ball.class.name,
18
+ created_at: Time.now.floor(300),
19
+ name: 'rotations',
20
+ value: 3
21
+ }.with_indifferent_access
22
+
23
+ counter.attributes.except('id').keys.each do |attr|
24
+ expect(counter[attr]).to be_eql(expected[attr])
25
+ end
26
+ }.to change { EventCounter.count }.by(1)
27
+ end
28
+
29
+ it '#make on time' do
30
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
31
+ expect {
32
+ counter = ball.rotations.make(
33
+ 3, on_time: on_time)
34
+ expect(counter).to be_a(EventCounter)
35
+
36
+ expected = {
37
+ countable_id: ball.id,
38
+ countable_type: ball.class.name,
39
+ created_at: on_time.change(min: 10),
40
+ name: 'rotations',
41
+ value: 3
42
+ }.with_indifferent_access
43
+
44
+ counter.attributes.except('id').keys.each do |attr|
45
+ expect(counter[attr]).to be_eql(expected[attr])
46
+ end
47
+ }.to change { EventCounter.count }.by(1)
48
+ end
49
+
50
+ it '#make on time with interval as symbol' do
51
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
52
+ [:week, :month, :year].each do |interval|
53
+ expect {
54
+ counter = ball.send(:"rotations_by_#{interval}").make(
55
+ 3, on_time: on_time)
56
+ expect(counter).to be_a(EventCounter)
57
+
58
+ expected = {
59
+ countable_id: ball.id,
60
+ countable_type: ball.class.name,
61
+ created_at: on_time.send(:"beginning_of_#{interval}"),
62
+ name: "rotations_by_#{interval}",
63
+ value: 3
64
+ }.with_indifferent_access
65
+
66
+ counter.attributes.except('id').keys.each do |attr|
67
+ expect(counter[attr]).to be_eql(expected[attr])
68
+ end
69
+ }.to change { EventCounter.count }.by(1)
70
+ end
71
+ end
72
+
73
+ end
74
+
75
+ describe Ball do
76
+ let(:ball) { Fabricate(:ball) }
77
+
78
+ it 'creates a new counter while incrementing' do
79
+ expect {
80
+ expect(ball.up!(:rotations)).to be_a(EventCounter)
81
+ }.to change { EventCounter.count }.by(1)
82
+
83
+ on_time = Time.mktime(2011, 11, 11, 11, 11)
84
+ expect {
85
+ expect(ball.up!(:rotations, on_time: on_time))
86
+ .to be_a(EventCounter)
87
+ }.to change { EventCounter.count }.by(1)
88
+
89
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
90
+ expect {
91
+ expect(ball.up!(:rotations, 5, on_time: on_time))
92
+ .to be_a(EventCounter)
93
+ }.to change { EventCounter.count }.by(1)
94
+ end
95
+
96
+ it 'creates a new counter while decrementing' do
97
+ expect {
98
+ expect(ball.down!(:rotations)).to be_a(EventCounter)
99
+ }.to change { EventCounter.count }.by(1)
100
+
101
+ on_time = Time.mktime(2011, 11, 11, 11, 11)
102
+ expect {
103
+ expect(ball.down!(:rotations, on_time: on_time))
104
+ .to be_a(EventCounter)
105
+ }.to change { EventCounter.count }.by(1)
106
+
107
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
108
+ expect {
109
+ expect(ball.down!(:rotations, 5, on_time: on_time))
110
+ .to be_a(EventCounter)
111
+ }.to change { EventCounter.count }.by(1)
112
+ end
113
+
114
+ it 'increments existent counter with default value' do
115
+ counter = ball.rotations.make
116
+
117
+ expect {
118
+ expect {
119
+ expect(ball.up!(:rotations)).to be_a(EventCounter)
120
+ }.to change { counter.reload.value }.from(1).to(2)
121
+ }.to_not change { EventCounter.count }
122
+ end
123
+
124
+ it 'decrements existent counter with default value' do
125
+ counter = ball.rotations.make(- 1)
126
+
127
+ expect {
128
+ expect {
129
+ expect(ball.down!(:rotations)).to be_a(EventCounter)
130
+ }.to change { counter.reload.value }.from(-1).to(-2)
131
+ }.to_not change { EventCounter.count }
132
+ end
133
+
134
+ it 'increments existent counter by a specified value' do
135
+ counter = ball.rotations.make
136
+
137
+ expect {
138
+ expect {
139
+ expect(ball.up!(:rotations, 3)).to be_a(EventCounter)
140
+ }.to change { counter.reload.value }.from(1).to(4)
141
+ }.to_not change { EventCounter.count }
142
+ end
143
+
144
+ it 'decrements existent counter by a specified value' do
145
+ counter = ball.rotations.make 3
146
+
147
+ expect {
148
+ expect {
149
+ expect(ball.down!(:rotations, 5)).to be_a(EventCounter)
150
+ }.to change { counter.reload.value }.from(3).to(-2)
151
+ }.to_not change { EventCounter.count }
152
+ end
153
+
154
+ it 'increments existent counter on time with default value' do
155
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
156
+ counter = ball.rotations.make on_time: on_time
157
+
158
+ expect {
159
+ expect {
160
+ expect(ball.up!(:rotations, on_time: on_time.change(min: 14)))
161
+ }.to change { counter.reload.value }.from(1).to(2)
162
+ }.to_not change { EventCounter.count }
163
+ end
164
+
165
+ it 'decrements existent counter on time with default value' do
166
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
167
+ counter = ball.rotations.make on_time: on_time
168
+
169
+ expect {
170
+ expect {
171
+ expect(ball.down!(:rotations, on_time: on_time.change(min: 14)))
172
+ }.to change { counter.reload.value }.from(1).to(0)
173
+ }.to_not change { EventCounter.count }
174
+ end
175
+
176
+ it 'increments existent counter on time with specified value' do
177
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
178
+ counter = ball.rotations.make 2, on_time: on_time
179
+
180
+ expect {
181
+ expect {
182
+ expect(ball.up!(:rotations, 3, on_time: on_time.change(min: 14)))
183
+ }.to change { counter.reload.value }.from(2).to(5)
184
+ }.to_not change { EventCounter.count }
185
+ end
186
+
187
+ it 'decrements existent counter on time with specified value' do
188
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
189
+ counter = ball.rotations.make 2, on_time: on_time
190
+
191
+ expect {
192
+ expect {
193
+ expect(ball.down!(:rotations, 3, on_time: on_time.change(min: 14)))
194
+ }.to change { counter.reload.value }.from(2).to(-1)
195
+ }.to_not change { EventCounter.count }
196
+ end
197
+
198
+ it 'forces existent counter with new value' do
199
+ counter = ball.rotations.make
200
+
201
+ expect {
202
+ expect {
203
+ expect(ball.rotations.make(5, force: true))
204
+ .to be_a(EventCounter)
205
+ }.to change { counter.reload.value }.from(1).to(5)
206
+ }.to_not change { EventCounter.count }
207
+ end
208
+
209
+ it 'forces existent counter on time with new value' do
210
+ on_time = Time.mktime(2012, 12, 12, 12, 12)
211
+ counter = ball.rotations.make 2, on_time: on_time
212
+
213
+ expect {
214
+ expect {
215
+ expect(ball.rotations.make(5, force: true, on_time: on_time))
216
+ .to be_a(EventCounter)
217
+ }.to change { counter.reload.value }.from(2).to(5)
218
+ }.to_not change { EventCounter.count }
219
+ end
220
+
221
+ it 'raises error on wrong direction foc counter' do
222
+ expect { ball.send(:rotate_counter, *[:rotations, vector: :wrong_direction]) }
223
+ .to raise_error(EventCounter::CounterError, /wrong direction/i)
224
+ end
225
+
226
+ it 'raises error on unable to find counter' do
227
+ expect { ball.up!(:unknown) }
228
+ .to raise_error(EventCounter::CounterError, /unable to find/i)
229
+ end
230
+
231
+ def setup_counters(countable_count = 1)
232
+ [1, 1, 2, 3, 5, 8, 13, 21, 34].each do |n|
233
+ on_time = Time.mktime(2012, 12, 12, 12, n)
234
+ if countable_count == 1
235
+ ball.rotations.make n, on_time: on_time
236
+ else
237
+ countable_count.times do
238
+ Fabricate(:ball).rotations.make n, on_time: on_time
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ context '#data_for' do
245
+
246
+ before { setup_counters }
247
+
248
+ it 'with default interval' do
249
+ data = [
250
+ # [ minute, value ]
251
+ [ 0, 7 ],
252
+ [ 5, 13 ],
253
+ [ 10, 13 ],
254
+ [ 15, 0 ],
255
+ [ 20, 21 ],
256
+ [ 25, 0 ],
257
+ [ 30, 34 ]
258
+ ]
259
+ expect(ball.data_for(:rotations)).to eql_data(data)
260
+ end
261
+
262
+ it 'with a less interval' do
263
+ expect { ball.data_for(:rotations, interval: 3.minutes) }
264
+ .to raise_error(EventCounter::CounterError, /could not be less/i)
265
+
266
+ [:week, :month, :year].each do |interval|
267
+ expect { ball.data_for(:rotations_by_two_year, interval: interval) }
268
+ .to raise_error(EventCounter::CounterError, /could not be less/i)
269
+ end
270
+ end
271
+
272
+ it 'with a interval which is not a multiple of original interval' do
273
+ expect { ball.data_for(:rotations, interval: 7.minutes) }
274
+ .to raise_error(EventCounter::CounterError, /multiple of/i)
275
+ end
276
+
277
+ it 'with a greater interval' do
278
+ data = [ [ 0, 33 ], [ 20, 55 ] ]
279
+
280
+ expect(ball.data_for(:rotations, interval: 20.minutes))
281
+ .to eql_data(data)
282
+ end
283
+
284
+ it 'with a greater interval and a time range' do
285
+ range_start = Time.mktime 2012, 12, 12, 12, 15
286
+ range_end = Time.mktime 2012, 12, 12, 12, 45
287
+ range = range_start..range_end
288
+
289
+ data = [ [ 10, 13 ], [ 20, 21 ], [ 30, 34 ], [ 40, 0] ]
290
+
291
+ expect(ball.data_for(:rotations, interval: 10.minutes, range: range))
292
+ .to eql_data(data)
293
+ end
294
+
295
+ it 'with a greater interval as symbol' do
296
+ beginning_of_week = Time.mktime(2012, 12, 12).beginning_of_week
297
+
298
+ data = [ [ beginning_of_week, 88 ] ]
299
+
300
+ expect(ball.data_for(:rotations, interval: :week))
301
+ .to eql(data)
302
+ end
303
+
304
+ end
305
+
306
+ context '.data_for' do
307
+
308
+ before { setup_counters(3) }
309
+
310
+ it 'with a default interval' do
311
+ data = [
312
+ # [ minute, value ]
313
+ [ 0, 21 ],
314
+ [ 5, 39 ],
315
+ [ 10, 39 ],
316
+ [ 15, 0 ],
317
+ [ 20, 63 ],
318
+ [ 25, 0 ],
319
+ [ 30, 102 ]
320
+ ]
321
+ expect(subject.data_for(:rotations)).to eql_data(data)
322
+ end
323
+
324
+ it 'with a greater interval as symbol and a simple data' do
325
+ beginning_of_month = Time.mktime(2012, 12, 12).beginning_of_month
326
+ data = [ [ beginning_of_month, 264 ] ]
327
+ expect(subject.data_for(:rotations, interval: :month)).to eql(data)
328
+ end
329
+
330
+ it 'with a greater interval as symbol and a large data' do
331
+ EventCounter.all.each do |counter|
332
+ 11.times do |x|
333
+ created_at = counter.created_at - (x + 1).months
334
+ EventCounter.create!(counter.attributes.except('id')) do |c|
335
+ c.created_at = created_at
336
+ end
337
+ end
338
+ end
339
+
340
+ data = (1..12).map { |x| [ Time.mktime(2012, x), 264 ] }
341
+
342
+ expect(subject.data_for(:rotations, interval: :month)).to eql(data)
343
+ end
344
+
345
+ end
346
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+
3
+ describe Ball, performance: true do
4
+
5
+ let(:range_start) { (Time.mktime 2011, 12).beginning_of_month }
6
+ let(:range_end) { (Time.mktime 2012, 11).end_of_month }
7
+ let(:range) { range_start..range_end }
8
+
9
+ before(:all) do
10
+ @logger = ActiveRecord::Base.logger
11
+ ActiveRecord::Base.logger = nil
12
+ end
13
+
14
+ after(:all) do
15
+ ActiveRecord::Base.logger = @logger
16
+ end
17
+
18
+ def setup_data(countable = 10, step = 1.day)
19
+ (1..countable).map do |x|
20
+ ball = Fabricate(:ball)
21
+
22
+ (range_start.to_i..range_end.to_i).step(step) do |i|
23
+ on_time = Time.at(i)
24
+ ball.rotations.make on_time: on_time
25
+ end
26
+ end
27
+ end
28
+
29
+ context '#data_for' do
30
+
31
+ before { setup_data }
32
+
33
+ it 'performance is adequate' do
34
+ data = [
35
+ {"created_at"=>"2011-12-01 00:00:00+04", "value"=>"310"},
36
+ {"created_at"=>"2012-01-01 00:00:00+04", "value"=>"310"},
37
+ {"created_at"=>"2012-02-01 00:00:00+04", "value"=>"290"},
38
+ {"created_at"=>"2012-03-01 00:00:00+04", "value"=>"310"},
39
+ {"created_at"=>"2012-04-01 00:00:00+04", "value"=>"300"},
40
+ {"created_at"=>"2012-05-01 00:00:00+04", "value"=>"310"},
41
+ {"created_at"=>"2012-06-01 00:00:00+04", "value"=>"300"},
42
+ {"created_at"=>"2012-07-01 00:00:00+04", "value"=>"310"},
43
+ {"created_at"=>"2012-08-01 00:00:00+04", "value"=>"310"},
44
+ {"created_at"=>"2012-09-01 00:00:00+04", "value"=>"300"},
45
+ {"created_at"=>"2012-10-01 00:00:00+04", "value"=>"310"},
46
+ {"created_at"=>"2012-11-01 00:00:00+04", "value"=>"300"}
47
+ ]
48
+
49
+ expect {
50
+ expect(Ball.data_for(:rotations, interval: :month, range: range, raw: true))
51
+ .to eql(data)
52
+ }.to take_less_than(0.1).seconds
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+
4
+ require 'active_record'
5
+ require 'database_cleaner'
6
+ require 'fabrication'
7
+ require 'logger'
8
+ require 'event_counter'
9
+
10
+ logfile_path = File.expand_path('../../log/test.log', __FILE__)
11
+ ActiveRecord::Base.logger = Logger.new(logfile_path)
12
+
13
+ conf_local = File.expand_path('../../config/database.yml', __FILE__)
14
+ conf_ci = File.expand_path('../../config/database.ci.yml', __FILE__)
15
+
16
+ # Assume Travis CI database config if no custom one exists
17
+ conf = File.exist?(conf_local) ? conf_local : conf_ci
18
+
19
+ YAML.load(File.open(conf).read).values.each do |config|
20
+ ActiveRecord::Base.establish_connection config
21
+ end
22
+
23
+ ActiveRecord::Schema.define do
24
+ self.verbose = false
25
+
26
+ create_table :cubes, force: true
27
+
28
+ create_table :balls, force: true
29
+
30
+ create_table :event_counters, force: true do |t|
31
+ t.string :name, null: false
32
+ t.integer :value, default: 0, null: false
33
+ t.references :countable, polymorphic: true, null: false
34
+
35
+ t.datetime :created_at
36
+ end
37
+
38
+ add_index :event_counters, :countable_type
39
+ add_index :event_counters, [:countable_type, :name, :countable_id, :created_at],
40
+ name: 'composite'
41
+ end
42
+
43
+ # :nodoc:
44
+ class Ball < ActiveRecord::Base
45
+ event_counter_for :rotations, 5.minutes
46
+ event_counter_for :rotations_by_week, :week
47
+ event_counter_for :rotations_by_month, :month
48
+ event_counter_for :rotations_by_year, :year
49
+ event_counter_for :rotations_by_two_year, 2.years
50
+ end
51
+
52
+ Dir[File.expand_path('../support/*.rb', __FILE__)].each do |file|
53
+ require file
54
+ end
55
+
56
+ RSpec.configure do |config|
57
+ config.before(:suite) do
58
+ DatabaseCleaner.strategy = :transaction
59
+ DatabaseCleaner.clean_with(:truncation)
60
+ end
61
+
62
+ config.before(:each) do
63
+ DatabaseCleaner.start
64
+ end
65
+
66
+ config.after(:each) do
67
+ DatabaseCleaner.clean
68
+ end
69
+
70
+ config.filter_run_excluding performance: true
71
+ end
@@ -0,0 +1,2 @@
1
+ Fabricator(:ball) do
2
+ end
@@ -0,0 +1,31 @@
1
+ RSpec::Matchers.define :be_eql do |expected|
2
+ match do |actual|
3
+ if expected.is_a?(Time)
4
+ expect(expected.strftime('%d-%m-%Y %H:%M:%S'))
5
+ .to eq(actual.strftime('%d-%m-%Y %H:%M:%S'))
6
+ else
7
+ expect(expected).to eql(actual)
8
+ end
9
+ end
10
+
11
+ diffable
12
+ end
13
+
14
+ RSpec::Matchers.define :eql_data do |expected|
15
+ match do |actual|
16
+ expected.map! { |a, b| [ Time.mktime(2012, 12, 12, 12, a), b ] }
17
+ expect(actual).to eql(expected)
18
+ end
19
+ end
20
+
21
+ require 'benchmark'
22
+
23
+ RSpec::Matchers.define :take_less_than do |expected|
24
+ chain :seconds do; end
25
+ match do |block|
26
+ elapsed = Benchmark.realtime { block.call }
27
+ elapsed <= expected
28
+ end
29
+
30
+ supports_block_expectations
31
+ end
metadata ADDED
@@ -0,0 +1,165 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: event-counter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Anton Orel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
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: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.7'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: database_cleaner
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: fabrication
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.0'
111
+ description: Database based event counter with throttling per time intervals
112
+ email:
113
+ - eagle.anton@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".gitignore"
119
+ - ".rubocop.yml"
120
+ - Gemfile
121
+ - LICENSE.txt
122
+ - README.md
123
+ - Rakefile
124
+ - config/database.ci.yml
125
+ - event-counter.gemspec
126
+ - lib/event-counter.rb
127
+ - lib/event_counter.rb
128
+ - lib/event_counter/active_record_extension.rb
129
+ - lib/event_counter/version.rb
130
+ - spec/lib/event_counter_spec.rb
131
+ - spec/lib/performance_spec.rb
132
+ - spec/spec_helper.rb
133
+ - spec/support/fabrications.rb
134
+ - spec/support/matchers.rb
135
+ homepage: https://github.com/skyeagle/event-counter
136
+ licenses:
137
+ - MIT
138
+ metadata: {}
139
+ post_install_message:
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubyforge_project:
155
+ rubygems_version: 2.2.1
156
+ signing_key:
157
+ specification_version: 4
158
+ summary: Event counter with throttling per time interval
159
+ test_files:
160
+ - spec/lib/event_counter_spec.rb
161
+ - spec/lib/performance_spec.rb
162
+ - spec/spec_helper.rb
163
+ - spec/support/fabrications.rb
164
+ - spec/support/matchers.rb
165
+ has_rdoc: