event-counter 0.0.1

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: 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: