has_metrics 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ .rvmrc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ test/test.db
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in has_metrics.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Allan Grant
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,51 @@
1
+ # HasMetrics
2
+
3
+ Calculates metrics on ActiveRecord entries and caches them so they can be queried from a database. The calculated values are stored in another table which gets automatically created and migrated as needed.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'has_metrics'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install has_metrics
18
+
19
+ ## Usage
20
+
21
+ # Memoize in DB for 24 hours as an integer
22
+ has_metric :total_offer_count do
23
+ activities.by_action(:offer).count
24
+ end
25
+
26
+ # Memoize in DB for 1 hour as an integer
27
+ has_metric :total_share_count, :every => 1.hour do
28
+ activities.by_action(:share).count
29
+ end
30
+
31
+ # Memoize in DB for 24 hours as a float
32
+ has_metric :average_shares_per_offer, :type => :float do
33
+ total_share_count.to_f / total_offer_count
34
+ end
35
+
36
+ ## TODO
37
+
38
+ 1. Tests
39
+ 2. Refactoring
40
+ 3. Better readme
41
+ 4. Extract related functionality into gem
42
+ * segments - lets you use has_metrics to segment all records in a table between some set of string values
43
+ * has_custom_order_by - provides default names scopes for sorting based on metrics & segments by joining the metrics table
44
+
45
+ ## Contributing
46
+
47
+ 1. Fork it
48
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
49
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
50
+ 4. Push to the branch (`git push origin my-new-feature`)
51
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ require 'rake/testtask'
5
+ Rake::TestTask.new(:test) do |test|
6
+ test.libs << 'lib' << 'test'
7
+ test.pattern = 'test/{functional,unit}/**/*_test.rb'
8
+ test.verbose = true
9
+ end
10
+
11
+ task :default => :test
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/has_metrics/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Allan Grant"]
6
+ gem.email = ["allan@allangrant.net"]
7
+ gem.description = %q{Memoization into activerecord.}
8
+ gem.summary = %q{Calculate "metrics" (any expensive methods) on ActiveRecord entries and memoize them to an automagical table.}
9
+ gem.homepage = "http://github.com/allangrant/has_metrics"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "has_metrics"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = HasMetrics::VERSION
17
+
18
+ gem.add_dependency("activerecord")
19
+ gem.add_development_dependency("rake")
20
+ gem.add_development_dependency("shoulda")
21
+ # gem.add_development_dependency("mocha")
22
+ gem.add_development_dependency("sqlite3")
23
+ end
@@ -0,0 +1,185 @@
1
+ module Metrics
2
+ def self.included(base)
3
+ base.extend ClassMethods
4
+
5
+ klass_name = "#{base}Metrics"
6
+ klass = begin
7
+ Object.const_get(klass_name)
8
+ rescue
9
+ Object.const_set(klass_name, Class.new(ActiveRecord::Base))
10
+ end
11
+ klass.class_eval do
12
+ extend Metrics::MetricsClass
13
+ belongs_to base.to_s.underscore.to_sym, :foreign_key => 'id'
14
+ @object_class = base
15
+ end
16
+
17
+ base.class_eval do
18
+ if klass.table_exists?
19
+ @metrics_class = klass
20
+ has_one :metrics, :class_name => klass_name, :foreign_key => 'id', :dependent => :destroy
21
+ else
22
+ @object_class = base
23
+ @metrics_class = base
24
+ base.extend(Metrics::MetricsClass)
25
+ end
26
+
27
+ def metrics
28
+ @metrics ||= self.class.metrics_class.find_or_create_by_id(id)
29
+ end
30
+ end
31
+ end
32
+
33
+ module ClassMethods
34
+ # CLASS METHODS ADDED
35
+ def metrics_class
36
+ @metrics_class
37
+ end
38
+ def has_metric name, options={}, &block
39
+ define_method name do |*args|
40
+ frequency = options[:every] || 20.hours
41
+ previous_result = metrics.attributes[name.to_s] unless options[:every] == :always
42
+ datestamp_column = "updated__#{name}__at"
43
+ datestamp = metrics.attributes[datestamp_column]
44
+ force = [:force, true].include?(args[0])
45
+ case
46
+ when !force && previous_result && options[:once]
47
+ # Only calculate this metric once. If it's not nil, reuse the old value.
48
+ previous_result
49
+ when !force && frequency.is_a?(Fixnum) && datestamp && datestamp > frequency.ago
50
+ # The metric was recently calculated and can be reused.
51
+ previous_result
52
+ else
53
+ result = instance_exec(&block)
54
+ result = nil if result.is_a?(Float) && !result.finite?
55
+ begin
56
+ metrics.update_attributes(name => result, datestamp_column => Time.zone.now)
57
+ rescue NoMethodError
58
+ # This happens if the migrations haven't run yet. We should still calculate & return the metric.
59
+ end
60
+ result
61
+ end
62
+ end
63
+
64
+ (@metrics ||= []) << name.to_sym
65
+
66
+ if respond_to?(:has_custom_order_by) # TODO: carve out has_custom_order_by functionality into this gem
67
+ unless metrics_class == self
68
+ has_custom_order_by name do |column, order|
69
+ { :joins => :metrics, :order => "#{reflect_on_association(:metrics).table_name}.#{column} #{order}" }
70
+ end
71
+ end
72
+ end
73
+
74
+ if options[:type] && (options[:type].to_sym == :float)
75
+ (@float_metrics ||= []) << name.to_sym
76
+ end
77
+ end
78
+
79
+ def metrics
80
+ @metrics
81
+ end
82
+
83
+ def metrics_column_type(column)
84
+ case
85
+ when (column.to_s =~ /^by_(.+)$/) && respond_to?(:segment_categories) && segment_categories.include?($1.to_sym) # TODO: carve out segementation functionality into this gem
86
+ :string
87
+ when (column.to_s =~ /_at$/)
88
+ :datetime
89
+ when @float_metrics && @float_metrics.include?(column)
90
+ :float
91
+ else :integer
92
+ end
93
+ end
94
+
95
+ def update_all_metrics!(*args)
96
+ metrics_class.migrate!
97
+ # start_time = Time.zone.now
98
+ # total = all.count
99
+ # if caller.find {|c| c =~ /irb_binding/} # When called from irb
100
+ # puts "Updating all metrics on #{name}: #{metrics.join(', ')}"
101
+ # puts "Updating #{total} records."
102
+ # progress_bar = ProgressBar.new("Progress", total)
103
+ # end
104
+ for record in all(:order => "id desc")
105
+ record.update_metrics!(*args)
106
+ # progress_bar.inc if progress_bar
107
+ end
108
+ # progress_bar.finish if progress_bar
109
+ # elapsed = Time.zone.now - start_time
110
+ # Notifier.deliver_simple_message('allan@curebit.com', '[CUREBIT] Metrics computation time', "Finished calculating #{metrics.count} metrics on #{total} #{name.underscore.humanize.downcase.pluralize} in #{elapsed/60} minutes (#{elapsed/total} sec per entry) (#{elapsed/(total*metrics.count)} sec per metric). \n\nMetrics calculated:\n\n#{metrics.join("\n")}")
111
+ metrics
112
+ end
113
+ end
114
+ ### END CLASS METHODS, START INSTANCE METHODS
115
+
116
+ def update_metrics!(*args)
117
+ self.class.metrics.each do |metric|
118
+ send(metric, *args)
119
+ end
120
+ end
121
+ ### END INSTANCE METHODS
122
+
123
+ ### Sets up a class like "SiteMetrics". These are all CLASS methods:
124
+ module MetricsClass
125
+ def object_class
126
+ @object_class
127
+ end
128
+
129
+ def metrics_updated_at_columns
130
+ @object_class.metrics.map{|metric| "updated__#{metric}__at"}
131
+ end
132
+
133
+ def required_columns
134
+ @object_class.metrics.map(&:to_s) + metrics_updated_at_columns
135
+ end
136
+
137
+ def missing_columns
138
+ reset_column_information
139
+ required_columns - (columns.map(&:name) - %w(id created_at updated_at))
140
+ end
141
+
142
+ def extra_columns
143
+ reset_column_information
144
+ if @object_class == self
145
+ raise "Cannot determine if there were extra columns for has_metric when using the table itself for storing the metric! Remove any columns manually"
146
+ [] # We wont know what columns are excessive if the source changed
147
+ else
148
+ (columns.map(&:name) - %w(id created_at updated_at)).map - required_columns
149
+ end
150
+
151
+ end
152
+
153
+ class Metrics::Migration < ActiveRecord::Migration
154
+ def self.setup(metrics)
155
+ @metrics = metrics
156
+ end
157
+ def self.up
158
+ @metrics.missing_columns.each do |column|
159
+ column_type = @metrics.object_class.metrics_column_type(column)
160
+ add_column @metrics.table_name, column, column_type, (column_type==:string ? {:null => false, :default => ''} : {})
161
+ end
162
+ end
163
+ def self.down
164
+ @metrics.extra_columns.each do |column|
165
+ remove_column @metrics.table_name, column
166
+ end
167
+ end
168
+ end
169
+
170
+ def remigrate!
171
+ old_metrics = @object_class.metrics
172
+ @object_class.class_eval { @metrics = [] }
173
+ migrate!
174
+ @object_class.class_eval { @metrics = old_metrics }
175
+ migrate!
176
+ end
177
+
178
+ def migrate!
179
+ Metrics::Migration.setup(self)
180
+ Metrics::Migration.down unless @object_class == self || extra_columns.empty?
181
+ Metrics::Migration.up unless missing_columns.empty?
182
+ reset_column_information
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,3 @@
1
+ module HasMetrics
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,6 @@
1
+ require "has_metrics/version"
2
+ require "has_metrics/metrics"
3
+
4
+ module HasMetrics
5
+
6
+ end
@@ -0,0 +1,58 @@
1
+ require File.expand_path(File.dirname(__FILE__) + "/../test_helper")
2
+
3
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3",
4
+ :database => File.expand_path(File.dirname(__FILE__) + "/../test.db"))
5
+
6
+ class CreateTestTables < ActiveRecord::Migration
7
+ def self.up
8
+ create_table "users", :force => true do |t|
9
+ t.string "name"
10
+ end
11
+ create_table "user_metrics", :force => true
12
+ end
13
+
14
+ def self.down
15
+ drop_table "users"
16
+ drop_table "user_metrics"
17
+ end
18
+ end
19
+
20
+
21
+ class User < ActiveRecord::Base
22
+ include Metrics
23
+ has_metric :name_length do
24
+ name.length
25
+ end
26
+ end
27
+
28
+
29
+ class MetricsTest < Test::Unit::TestCase
30
+ context "when defining metrics" do
31
+ setup do
32
+ root = File.expand_path(File.join(File.dirname(__FILE__), '..'))
33
+
34
+ CreateTestTables.up
35
+ User.update_all_metrics!
36
+ @user_name =
37
+ @user = User.create(:name => "Fuzz")
38
+ end
39
+
40
+ should "create rows for the metrics" do
41
+ assert_equal 3, UserMetrics.columns.count
42
+ User.has_metric :name_length_squared do
43
+ name_length * name_length
44
+ end
45
+ User.update_all_metrics!
46
+ assert_equal 5, UserMetrics.columns.count
47
+ assert_equal 16, @user.name_length_squared
48
+ end
49
+
50
+ should "they should calculate their block when called" do
51
+ assert_equal "Fuzz", @user.name
52
+ assert_equal 4, @user.name_length
53
+ @user.name = "Bib"
54
+ assert_equal 3, @user.name_length
55
+ assert_equal 4, User.find_by_name("Fuzz").name_length
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+
3
+ # Want to test the files here, in lib, not in an installed version of the gem.
4
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
5
+
6
+ require 'shoulda'
7
+ # require 'mocha'
8
+ require 'activerecord'
9
+ require 'sqlite3'
10
+ require 'has_metrics/metrics'
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: has_metrics
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Allan Grant
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2012-04-07 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ version_requirements: &id001 !ruby/object:Gem::Requirement
23
+ none: false
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ hash: 3
28
+ segments:
29
+ - 0
30
+ version: "0"
31
+ requirement: *id001
32
+ prerelease: false
33
+ name: activerecord
34
+ type: :runtime
35
+ - !ruby/object:Gem::Dependency
36
+ version_requirements: &id002 !ruby/object:Gem::Requirement
37
+ none: false
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ hash: 3
42
+ segments:
43
+ - 0
44
+ version: "0"
45
+ requirement: *id002
46
+ prerelease: false
47
+ name: rake
48
+ type: :development
49
+ - !ruby/object:Gem::Dependency
50
+ version_requirements: &id003 !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ hash: 3
56
+ segments:
57
+ - 0
58
+ version: "0"
59
+ requirement: *id003
60
+ prerelease: false
61
+ name: shoulda
62
+ type: :development
63
+ - !ruby/object:Gem::Dependency
64
+ version_requirements: &id004 !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ hash: 3
70
+ segments:
71
+ - 0
72
+ version: "0"
73
+ requirement: *id004
74
+ prerelease: false
75
+ name: sqlite3
76
+ type: :development
77
+ description: Memoization into activerecord.
78
+ email:
79
+ - allan@allangrant.net
80
+ executables: []
81
+
82
+ extensions: []
83
+
84
+ extra_rdoc_files: []
85
+
86
+ files:
87
+ - .gitignore
88
+ - Gemfile
89
+ - LICENSE
90
+ - README.md
91
+ - Rakefile
92
+ - has_metrics.gemspec
93
+ - lib/has_metrics.rb
94
+ - lib/has_metrics/metrics.rb
95
+ - lib/has_metrics/version.rb
96
+ - test/functional/metrics_test.rb
97
+ - test/test_helper.rb
98
+ has_rdoc: true
99
+ homepage: http://github.com/allangrant/has_metrics
100
+ licenses: []
101
+
102
+ post_install_message:
103
+ rdoc_options: []
104
+
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ hash: 3
113
+ segments:
114
+ - 0
115
+ version: "0"
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ hash: 3
122
+ segments:
123
+ - 0
124
+ version: "0"
125
+ requirements: []
126
+
127
+ rubyforge_project:
128
+ rubygems_version: 1.5.3
129
+ signing_key:
130
+ specification_version: 3
131
+ summary: Calculate "metrics" (any expensive methods) on ActiveRecord entries and memoize them to an automagical table.
132
+ test_files:
133
+ - test/functional/metrics_test.rb
134
+ - test/test_helper.rb