arca 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0e3c9c827e804d9e159cd1287dd96d2f51a73b7c
4
+ data.tar.gz: 2258bba04de95cb191e8db9819ff6b170cd96ab0
5
+ SHA512:
6
+ metadata.gz: a769d84288997f5dcb1557d3e8061b2c215a1bf492f63da1c9d84d4b37a6552a59b90a53f6531104e0b2ecfe0cdf20b88b34c6611d243b85385cfff04a24ae85
7
+ data.tar.gz: cbebb0af251cfbd5d8900d85aaaa9c973e8ca29a03cb9d73a94d0591f640b6cf6e7f5e03455859bea967346a01e2481bbae49949b6fb06ca8573c8dc3228a74b
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
@@ -0,0 +1,26 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ arca (1.0.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ coderay (1.1.0)
10
+ method_source (0.8.2)
11
+ minitest (5.7.0)
12
+ pry (0.10.1)
13
+ coderay (~> 1.1.0)
14
+ method_source (~> 0.8.1)
15
+ slop (~> 3.4)
16
+ rake (10.4.2)
17
+ slop (3.5.0)
18
+
19
+ PLATFORMS
20
+ ruby
21
+
22
+ DEPENDENCIES
23
+ arca!
24
+ minitest (~> 5.7)
25
+ pry (~> 0.10.1)
26
+ rake (~> 10.4)
@@ -0,0 +1,188 @@
1
+ # ActiveRecord Callback Analyzer
2
+
3
+ Arca is a callback analyzer for ActiveRecord like models ideally suited for digging yourself out of callback hell. Arca helps you answer questions like:
4
+
5
+ * how spread out callbacks are for each model
6
+ * how many callbacks use conditionals (`:if` and `:unless`)
7
+ * how many possible permutations exist per callback type (`:commit`, `:create`, `:destroy`, `:find`, `:initialize`, `:rollback`, `:save`, `:touch`, `:update`, `:validation`) taking conditionals into consideration
8
+
9
+ The Arca library has two main components, the collector and the reporter. Include the collector module in each ActiveRecord model you want to analyze and then use the reporter to analyze and present the data.
10
+
11
+ ## Usage
12
+
13
+ Add the gem to your Gemfile and run `bundle`.
14
+
15
+ ```
16
+ gem 'arca'
17
+ ```
18
+
19
+ Add an initializer to require the library and configure it (`config/initializers/arca.rb` for example). There's no magic here and Arca doesn't assume your root project path or the path to your `ActiveRecord` models so you have to specify those paths yourself in the initializer.
20
+
21
+ ```
22
+ require "arca"
23
+
24
+ Arca.root_path = Rails.root
25
+ Arca.model_path = Rails.root.join("app", "models")
26
+ ```
27
+
28
+ Include `Arca::Collector` in the models you want to analyze. It must be included before any callbacks so I recommend including it right after the class definition.
29
+
30
+ ```ruby
31
+ class Ticket < ActiveRecord::Base
32
+ include Arca::Collector
33
+ include Announcements
34
+
35
+ before_save :set_title, :set_body
36
+ before_save :upcase_title, :if => :title_is_a_shout?
37
+
38
+ def set_title
39
+ self.title ||= "Ticket id #{SecureRandom.hex(2)}"
40
+ end
41
+
42
+ def set_body
43
+ self.body ||= "Everything is broken."
44
+ end
45
+
46
+ def upcase_title
47
+ self.title = title.upcase
48
+ end
49
+
50
+ def title_is_a_shout?
51
+ self.title.split(" ").size == 1
52
+ end
53
+ end
54
+ ```
55
+
56
+ In this example the `Annoucements` module is included in `Ticket` and defines it's own callback.
57
+
58
+ ```ruby
59
+ module Announcements
60
+ def self.included(base)
61
+ base.class_eval do
62
+ after_save :announce_save
63
+ end
64
+ end
65
+
66
+ def announce_save
67
+ puts "saved #{self.class.name.downcase}!"
68
+ end
69
+ end
70
+ ```
71
+
72
+ Next use `Arca[Ticket].report` to analyze the callbacks for the `Ticket` class.
73
+
74
+ ```ruby
75
+ > Arca.root_path = `pwd`.chomp
76
+ => "/Users/jonmagic/Projects/arca"
77
+ > Arca[Ticket].report
78
+ {
79
+ :model_name => "Ticket",
80
+ :model_file_path => "test/fixtures/ticket.rb",
81
+ :callbacks_count => 4,
82
+ :conditionals_count => 1,
83
+ :lines_between_count => 6,
84
+ :external_callbacks_count => 1,
85
+ :external_targets_count => 0,
86
+ :external_conditionals_count => 0,
87
+ :calculated_permutations => 2
88
+ }
89
+ > Arca[Ticket].analyzed_callbacks
90
+ {
91
+ :before_save => [
92
+ {
93
+ :callback => :before_save,
94
+ :callback_file_path => "test/fixtures/ticket.rb",
95
+ :callback_line_number => 5,
96
+ :external_callback => false,
97
+ :target => :set_title,
98
+ :target_file_path => "test/fixtures/ticket.rb",
99
+ :target_line_number => 8,
100
+ :external_target => false,
101
+ :lines_to_target => 3,
102
+ :conditional => nil,
103
+ :conditional_target => nil,
104
+ :conditional_target_file_path => nil,
105
+ :conditional_target_line_number => nil,
106
+ :external_conditional_target => nil,
107
+ :lines_to_conditional_target => nil
108
+ },
109
+ {
110
+ :callback => :before_save,
111
+ :callback_file_path => "test/fixtures/ticket.rb",
112
+ :callback_line_number => 5,
113
+ :external_callback => false,
114
+ :target => :set_body,
115
+ :target_file_path => "test/fixtures/ticket.rb",
116
+ :target_line_number => 12,
117
+ :external_target => false,
118
+ :lines_to_target => 7,
119
+ :conditional => nil,
120
+ :conditional_target => nil,
121
+ :conditional_target_file_path => nil,
122
+ :conditional_target_line_number => nil,
123
+ :external_conditional_target => nil,
124
+ :lines_to_conditional_target => nil
125
+ },
126
+ {
127
+ :callback => :before_save,
128
+ :callback_file_path => "test/fixtures/ticket.rb",
129
+ :callback_line_number => 6,
130
+ :external_callback => false,
131
+ :target => :upcase_title,
132
+ :target_file_path => "test/fixtures/ticket.rb",
133
+ :target_line_number => 16,
134
+ :external_target => false,
135
+ :lines_to_target => 10,
136
+ :conditional => :if,
137
+ :conditional_target => :title_is_a_shout?,
138
+ :conditional_target_file_path => "test/fixtures/ticket.rb",
139
+ :conditional_target_line_number => 20,
140
+ :external_conditional_target => false,
141
+ :lines_to_conditional_target => nil
142
+ }
143
+ ],
144
+ :after_save => [
145
+ {
146
+ :callback => :after_save,
147
+ :callback_file_path => "test/fixtures/announcements.rb",
148
+ :callback_line_number => 4,
149
+ :external_callback => true,
150
+ :target => :announce_save,
151
+ :target_file_path => "test/fixtures/announcements.rb",
152
+ :target_line_number => 8,
153
+ :external_target => false,
154
+ :lines_to_target => 4,
155
+ :conditional => nil,
156
+ :conditional_target => nil,
157
+ :conditional_target_file_path => nil,
158
+ :conditional_target_line_number => nil,
159
+ :external_conditional_target => nil,
160
+ :lines_to_conditional_target => nil
161
+ }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ ## License
167
+
168
+ The MIT License (MIT)
169
+
170
+ Copyright (c) 2015 Jonathan Hoyt
171
+
172
+ Permission is hereby granted, free of charge, to any person obtaining a copy
173
+ of this software and associated documentation files (the "Software"), to deal
174
+ in the Software without restriction, including without limitation the rights
175
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
176
+ copies of the Software, and to permit persons to whom the Software is
177
+ furnished to do so, subject to the following conditions:
178
+
179
+ The above copyright notice and this permission notice shall be included in all
180
+ copies or substantial portions of the Software.
181
+
182
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
183
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
184
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
185
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
186
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
187
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
188
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "test"
5
+ t.test_files = FileList["test/**/*_test.rb"]
6
+ end
7
+
8
+ task :default => :test
@@ -0,0 +1,19 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "arca"
3
+ spec.version = "1.0.0"
4
+ spec.date = "2015-07-11"
5
+ spec.summary = "ActiveRecord callback analyzer"
6
+ spec.description = "Arca is a callback analyzer for ActiveRecord ideally suited for digging yourself out of callback hell"
7
+ spec.authors = ["Jonathan Hoyt"]
8
+ spec.email = "jonmagic@gmail.com"
9
+ spec.require_paths = ["lib"]
10
+ spec.files = `git ls-files -z`.split("\x0")
11
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
12
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
13
+ spec.homepage = "https://github.com/jonmagic/arca"
14
+ spec.license = "MIT"
15
+
16
+ spec.add_development_dependency "rake", "~> 10.4"
17
+ spec.add_development_dependency "minitest", "~> 5.7"
18
+ spec.add_development_dependency "pry", "~> 0.10"
19
+ end
@@ -0,0 +1,64 @@
1
+ require_relative "arca/collector"
2
+ require_relative "arca/model"
3
+ require_relative "arca/report"
4
+ require_relative "arca/callback_analysis"
5
+
6
+ module Arca
7
+
8
+ # Error raised if Arca[] is passed something other than a class constant.
9
+ class ClassRequired < StandardError; end
10
+
11
+ # Error raised if model does not respond to arca_callback_data
12
+ class CallbackDataMissing < StandardError; end
13
+
14
+ # Public: Reader method for accessing the Arca::Model for analysis and
15
+ # reporting.
16
+ def self.[](klass)
17
+ raise ClassRequired unless klass.kind_of?(Class)
18
+ raise CallbackDataMissing unless klass.respond_to?(:arca_callback_data)
19
+
20
+ Arca::Model.new(klass)
21
+ end
22
+
23
+ # Public: (optional) Writer method for configuring the root path of the
24
+ # project where Arca is being used. Setting Arca.root_path will makes
25
+ # inspecting analyzed callbacks easier by shortening absolute paths to
26
+ # relative paths.
27
+ #
28
+ # path - Pathname or String representing the root path of the project.
29
+ def self.root_path=(path)
30
+ @root_path = path.to_s
31
+ end
32
+
33
+ # Public: String representing the root path for the project.
34
+ def self.root_path
35
+ @root_path
36
+ end
37
+
38
+ # Public: (required) Writer method for configuring the root path to the models
39
+ # for the project where Arca is being used. This path is required by the
40
+ # Arca::Collector for finding the correct line in the caller Array.
41
+ def self.model_root_path=(path)
42
+ @model_root_path = path.to_s
43
+ end
44
+
45
+ # Public: String representing the path to the models for the project.
46
+ def self.model_root_path
47
+ @model_root_path
48
+ end
49
+
50
+ # Public: Helper method for turning absolute paths into relative paths.
51
+ #
52
+ # path - String absolute path.
53
+ #
54
+ # Returns a relative path String.
55
+ def self.relative_path(path)
56
+ return if path.nil?
57
+
58
+ if @root_path
59
+ path.sub(/^#{Regexp.escape(@root_path) || ""}\//, "")
60
+ else
61
+ path
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,134 @@
1
+ module Arca
2
+ class CallbackAnalysis
3
+
4
+ # Arca::CallbackAnalysis takes an Arca::Model and data for a specific
5
+ # callback and then calculates and exposes a complete analysis for the
6
+ # callback including target methods, file paths, line numbers, booleans
7
+ # representing whether targets are in the same file they are called from,
8
+ # and finally the number of lines between callers and the target methods
9
+ # they call.
10
+ #
11
+ # model - Arca::Model instance.
12
+ # callback_data - Hash with callback data collected by Arca::Collector.
13
+ def initialize(model:, callback_data:)
14
+ @model = model
15
+ @callback_symbol = callback_data.fetch(:callback_symbol)
16
+ @callback_file_path = callback_data.fetch(:callback_file_path)
17
+ @callback_line_number = callback_data.fetch(:callback_line_number)
18
+ @target_symbol = callback_data.fetch(:target_symbol)
19
+ @conditional_symbol = callback_data[:conditional_symbol]
20
+ @conditional_target_symbol = callback_data[:conditional_target_symbol]
21
+ end
22
+
23
+ # Public: Hash representation of the object for interactive consoles.
24
+ def inspect
25
+ to_hash.to_s
26
+ end
27
+
28
+ # Public: Hash of collected and analyzed callback data.
29
+ def to_hash
30
+ {
31
+ :callback => callback_symbol,
32
+ :callback_file_path => Arca.relative_path(callback_file_path),
33
+ :callback_line_number => callback_line_number,
34
+ :external_callback => external_callback?,
35
+ :target => target_symbol,
36
+ :target_file_path => Arca.relative_path(target_file_path),
37
+ :target_line_number => target_line_number,
38
+ :external_target => external_target?,
39
+ :lines_to_target => lines_to_target,
40
+ :conditional => conditional_symbol,
41
+ :conditional_target => conditional_target_symbol,
42
+ :conditional_target_file_path => Arca.relative_path(conditional_target_file_path),
43
+ :conditional_target_line_number => conditional_target_line_number,
44
+ :external_conditional_target => external_conditional_target?,
45
+ :lines_to_conditional_target => nil
46
+ }
47
+ end
48
+
49
+ # Public: Arca::Model this callback belongs to.
50
+ attr_reader :model
51
+
52
+ # Public: Symbol representing the callback method name.
53
+ attr_reader :callback_symbol
54
+
55
+ # Public: String path to the file where the callback is used.
56
+ attr_reader :callback_file_path
57
+
58
+ # Public: Integer line number where the callback is called.
59
+ attr_reader :callback_line_number
60
+
61
+ # Public: Boolean representing whether the callback is used in the same
62
+ # file where the ActiveRecord model is defined.
63
+ def external_callback?
64
+ callback_file_path != model.file_path
65
+ end
66
+
67
+ # Public: Symbol representing the callback target method name.
68
+ attr_reader :target_symbol
69
+
70
+ # Public: String path to the file where the callback target is located.
71
+ def target_file_path
72
+ model.source_location(target_symbol)[:file_path]
73
+ end
74
+
75
+ # Public: Integer line number where the callback target is located.
76
+ def target_line_number
77
+ model.source_location(target_symbol)[:line_number]
78
+ end
79
+
80
+ # Public: Boolean representing whether the callback target is located in the
81
+ # same file where the callback is defined.
82
+ def external_target?
83
+ target_file_path != callback_file_path
84
+ end
85
+
86
+ # Public: Integer representing the number of lines between where the
87
+ # callback is used and the callback target is located.
88
+ def lines_to_target
89
+ return if external_target?
90
+
91
+ (target_line_number - callback_line_number).abs
92
+ end
93
+
94
+ # Public: Symbol representing the conditional target method name.
95
+ attr_reader :conditional_symbol
96
+ attr_reader :conditional_target_symbol
97
+
98
+ # Public: String path to the file where the conditional target is located.
99
+ def conditional_target_file_path
100
+ return if conditional_target_symbol.nil?
101
+
102
+ model.source_location(conditional_target_symbol)[:file_path]
103
+ end
104
+
105
+ # Public: Integer line number where the conditional target is located.
106
+ def conditional_target_line_number
107
+ return if conditional_target_symbol.nil?
108
+
109
+ model.source_location(conditional_target_symbol)[:line_number]
110
+ end
111
+
112
+ # Public: Boolean representing whether the conditional target is located in
113
+ # the same file where the callback is defined.
114
+ def external_conditional_target?
115
+ return if conditional_target_symbol.nil?
116
+
117
+ callback_file_path != conditional_target_file_path
118
+ end
119
+
120
+ # Public: Integer representing the number of lines between where the
121
+ # callback is used and the conditional target is located.
122
+ def lines_to_conditional_target
123
+ return if conditional_target_symbol.nil? || external_conditional_target?
124
+
125
+ (conditional_target_line_number - callback_line_number).abs
126
+ end
127
+
128
+ # Public: Boolean representing whether the callback target is located in the
129
+ # ActiveRecord gem.
130
+ def target_file_path_active_record?
131
+ target_file_path =~ /gems\/activerecord/
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,95 @@
1
+ module Arca
2
+
3
+ # Include Arca::Collector in an ActiveRecord class in order to collect data
4
+ # about how callbacks are being used.
5
+ module Collector
6
+
7
+ # Error raised if Arca.model_root_path is nil.
8
+ class ModelRootPathRequired < StandardError; end
9
+
10
+ # Internal: Regular expression used for extracting the file path and line
11
+ # number from a caller line.
12
+ ARCA_LINE_PARSER_REGEXP = /\A(.+)\:(\d+)\:in\s(.+)\z/
13
+ private_constant :ARCA_LINE_PARSER_REGEXP
14
+
15
+ # Internal: Array of conditional symbols.
16
+ ARCA_CONDITIONALS = [:if, :unless]
17
+ private_constant :ARCA_CONDITIONALS
18
+
19
+ # http://ruby-doc.org/core-2.2.1/Module.html#method-i-included
20
+ def self.included(base)
21
+ # Raise error if Arca.model_root_path is nil.
22
+ raise ModelRootPathRequired if Arca.model_root_path.nil?
23
+
24
+ # Get the file path to the model class that included the collector.
25
+ model_file_path, = caller[0].partition(":")
26
+
27
+ base.class_eval do
28
+ # Define :arca_callback_data for storing the data we collect.
29
+ define_singleton_method :arca_callback_data do
30
+ @callbacks ||= {}
31
+ end
32
+
33
+ # Collect the model_file_path.
34
+ arca_callback_data[:model_file_path] = model_file_path
35
+
36
+ # Find the callback methods defined on this class.
37
+ callback_method_symbols = singleton_methods.grep /^(after|around|before)\_/
38
+
39
+ callback_method_symbols.each do |callback_symbol|
40
+ # Find the UnboundMethod for the callback.
41
+ callback_method = singleton_class.instance_method(callback_symbol)
42
+
43
+ # Redefine the callback method so that data can be collected each time
44
+ # the callback is used for this class.
45
+ define_singleton_method(callback_method.name) do |*args|
46
+ # Duplicate args before modifying.
47
+ args_copy = args.dup
48
+
49
+ # Get the options hash from the end of the args Array if it exists.
50
+ options = args_copy.pop if args[-1].is_a?(Hash)
51
+
52
+ # Iterate through the rest of the args. Each remaining arguement is
53
+ # a Symbol representing the callback target method.
54
+ args_copy.each do |target_symbol|
55
+
56
+ # Find the caller line where the callback is used.
57
+ line = caller.find {|line| line =~ /#{Regexp.escape(Arca.model_root_path)}/ }
58
+
59
+ # Parse the line in order to extract the file path and line number.
60
+ callback_line_matches = line.match(ARCA_LINE_PARSER_REGEXP)
61
+
62
+ # Find the conditional symbol if it exists in the options hash.
63
+ conditional_symbol = ARCA_CONDITIONALS.
64
+ find {|conditional| options && options.has_key?(conditional) }
65
+
66
+ # Find the conditional target symbol if there is a conditional.
67
+ conditional_target_symbol = if conditional_symbol
68
+ options[conditional_symbol]
69
+ end
70
+
71
+ # Set the collector hash for this callback_symbol to an empty
72
+ # Array if it has not already been set.
73
+ arca_callback_data[callback_symbol] ||= []
74
+
75
+ # Add the collected callback data to the collector Array for
76
+ # this callback_symbol.
77
+ arca_callback_data[callback_symbol] << {
78
+ :callback_symbol => callback_symbol,
79
+ :callback_file_path => callback_line_matches[1],
80
+ :callback_line_number => callback_line_matches[2].to_i,
81
+ :target_symbol => target_symbol,
82
+ :conditional_symbol => conditional_symbol,
83
+ :conditional_target_symbol => conditional_target_symbol
84
+ }
85
+
86
+ end
87
+
88
+ # Bind the callback method to self and call it with args.
89
+ callback_method.bind(self).call(*args)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,121 @@
1
+ module Arca
2
+ class Model
3
+
4
+ # Arca::Model wraps an ActiveRecord model class and provides an interface
5
+ # to the collected and analyzed callback data for that class and the file
6
+ # path to the model class.
7
+ def initialize(klass)
8
+ @klass = klass
9
+ @name = klass.name
10
+ @callbacks = klass.arca_callback_data.dup
11
+ @file_path = callbacks.delete(:model_file_path)
12
+ end
13
+
14
+ # Array of ActiveRecord callback method symbols in a rough order of when
15
+ # they are used in the life cycle of an ActiveRecord model.
16
+ CALLBACKS = [
17
+ :after_initialize, :after_find, :after_touch, :before_validation, :after_validation,
18
+ :before_save, :around_save, :after_save, :before_create, :around_create,
19
+ :after_create, :before_update, :around_update, :after_update,
20
+ :before_destroy, :around_destroy, :after_destroy, :after_commit, :after_rollback
21
+ ]
22
+
23
+ # Public: ActiveRecord model class.
24
+ attr_reader :klass
25
+
26
+ # Public: String model name.
27
+ attr_reader :name
28
+
29
+ # Public: String file path.
30
+ attr_reader :file_path
31
+
32
+ # Public: Hash of collected callback data.
33
+ attr_reader :callbacks
34
+
35
+ # Public: Arca::Report for this model.
36
+ def report
37
+ @report ||= Report.new(self)
38
+ end
39
+
40
+ # Public: Helper method for finding the file path and line number where
41
+ # a method is located for the ActiveRecord model.
42
+ #
43
+ # method_symbol - Symbol representation of the method name.
44
+ def source_location(method_symbol)
45
+ source_location = klass.instance_method(method_symbol).source_location
46
+ {
47
+ :file_path => source_location[0],
48
+ :line_number => source_location[1]
49
+ }
50
+ rescue NameError
51
+ {
52
+ :file_path => nil,
53
+ :line_number => nil
54
+ }
55
+ rescue TypeError
56
+ {
57
+ :file_path => nil,
58
+ :line_number => nil
59
+ }
60
+ end
61
+
62
+ # Public: Hash of CallbackAnalysis objects for each callback type.
63
+ def analyzed_callbacks
64
+ @analyzed_callbacks ||= CALLBACKS.inject({}) do |result, callback_symbol|
65
+ Array(callbacks[callback_symbol]).each do |callback_data|
66
+ result[callback_symbol] ||= []
67
+ callback_analysis = CallbackAnalysis.new({
68
+ :model => self,
69
+ :callback_data => callback_data
70
+ })
71
+
72
+ unless callback_analysis.target_file_path_active_record?
73
+ result[callback_symbol] << callback_analysis
74
+ end
75
+ end
76
+ result
77
+ end
78
+ end
79
+
80
+ # Public: Array of all CallbackAnalysis objects for this model.
81
+ def analyzed_callbacks_array
82
+ @analyzed_callbacks_array ||= analyzed_callbacks.values.flatten
83
+ end
84
+
85
+ # Public: Integer representing the number of callbacks analyzed.
86
+ def analyzed_callbacks_count
87
+ analyzed_callbacks_array.size
88
+ end
89
+
90
+ # Public: Integer representing the total number of lines between callbacks
91
+ # called for this class from files other than the one where the class is
92
+ # defined.
93
+ def lines_between_count
94
+ lines_between = 0
95
+ line_numbers = analyzed_callbacks_array.map &:callback_line_number
96
+ sorted_line_numbers = line_numbers.sort {|a,b| b <=> a }
97
+ sorted_line_numbers.each_with_index do |line_number, index|
98
+ lines_between += line_number - (sorted_line_numbers[index + 1] || 0)
99
+ end
100
+ lines_between
101
+ end
102
+
103
+ # Public: Integer representing the number of callbacks called for this class
104
+ # from files other than this model.
105
+ def external_callbacks_count
106
+ analyzed_callbacks_array.select {|analysis| analysis.external_callback? }.size
107
+ end
108
+
109
+ # Public: Integer representing the number of callback targets that are
110
+ # defined in files other than this model.
111
+ def external_targets_count
112
+ analyzed_callbacks_array.select {|analysis| analysis.external_target? }.size
113
+ end
114
+
115
+ # Public: Integer representing the number of conditional callback targets
116
+ # that are defined in files other than this model.
117
+ def external_conditionals_count
118
+ analyzed_callbacks_array.select {|analysis| analysis.external_conditional_target? }.size
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,80 @@
1
+ module Arca
2
+ class Report
3
+
4
+ # Arca::Report takes an Arca::Model and compiles the analyzed callback data
5
+ # into a short overview report for the model.
6
+ def initialize(model)
7
+ @model = model
8
+ end
9
+
10
+ # Public: Arca::Model representing the ActiveRecord class being reported.
11
+ attr_reader :model
12
+
13
+ # Public: Hash representation of the object for interactive consoles.
14
+ def inspect
15
+ to_hash.to_s
16
+ end
17
+
18
+ # Public: Hash of compiled report data.
19
+ def to_hash
20
+ {
21
+ :model_name => model_name,
22
+ :model_file_path => Arca.relative_path(model_file_path),
23
+ :callbacks_count => callbacks_count,
24
+ :conditionals_count => conditionals_count,
25
+ :lines_between_count => lines_between_count,
26
+ :external_callbacks_count => external_callbacks_count,
27
+ :external_targets_count => external_targets_count,
28
+ :external_conditionals_count => external_conditionals_count,
29
+ :calculated_permutations => calculated_permutations
30
+ }
31
+ end
32
+
33
+ # Public: String class name of model.
34
+ def model_name
35
+ model.name
36
+ end
37
+
38
+ # Public: String file path of model.
39
+ def model_file_path
40
+ model.file_path
41
+ end
42
+
43
+ # Public: Integer representing the number of callbacks used in this model.
44
+ def callbacks_count
45
+ model.analyzed_callbacks_count
46
+ end
47
+
48
+ # Public: Integer representing the number of conditionals used in callback
49
+ # for the model being reported.
50
+ def conditionals_count
51
+ number_of_unique_conditionals(model.analyzed_callbacks_array)
52
+ end
53
+
54
+ delegate :lines_between_count, :external_callbacks_count,
55
+ :external_targets_count, :external_conditionals_count, :to => :model
56
+
57
+ # Public: Integer representing the possible number of permutations stemming
58
+ # from conditionals for an instance of the model being reported during the
59
+ # lifecycle of the object.
60
+ def calculated_permutations
61
+ permutations = model.analyzed_callbacks.inject([]) do |results, (key, analyzed_callbacks)|
62
+ results << 2 ** number_of_unique_conditionals(analyzed_callbacks)
63
+ end.sum - number_of_unique_conditionals(model.analyzed_callbacks_array)
64
+ end
65
+
66
+ # Internal: Integer representing the number of unique conditions for an
67
+ # Array of CallbackAnalysis objects.
68
+ #
69
+ # analyzed_callbacks - Array of CallbackAnalysis objects.
70
+ #
71
+ # Returns an Integer.
72
+ def number_of_unique_conditionals(analyzed_callbacks)
73
+ analyzed_callbacks.
74
+ select {|analysis| analysis.conditional_symbol }.
75
+ uniq {|analysis| analysis.conditional_target_symbol }.
76
+ size
77
+ end
78
+ private :number_of_unique_conditionals
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ module Announcements
2
+ def self.included(base)
3
+ base.class_eval do
4
+ after_save :announce_save
5
+ end
6
+ end
7
+
8
+ def announce_save
9
+ puts "saved #{self.class.name.downcase}!"
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ class Bar < ActiveRecord::Base
2
+ include Arca::Collector
3
+ end
@@ -0,0 +1,33 @@
1
+ class Foo
2
+ extend ActiveModel::Callbacks
3
+ define_model_callbacks :save
4
+
5
+ include Arca::Collector
6
+ before_save :bar, :baz, :if => :boop?
7
+
8
+ def initialize
9
+ @mission_complete = false
10
+ end
11
+
12
+ def save
13
+ run_callbacks :save do
14
+ @mission_complete = true
15
+ end
16
+ end
17
+ attr_reader :mission_complete
18
+
19
+ def bar
20
+ @bargo = "hello"
21
+ end
22
+ attr_reader :bargo
23
+
24
+ def baz
25
+ @bazinga = "world"
26
+ end
27
+ attr_reader :bazinga
28
+
29
+ def boop?
30
+ !!@boop
31
+ end
32
+ attr_writer :boop
33
+ end
@@ -0,0 +1,23 @@
1
+ class Ticket < ActiveRecord::Base
2
+ include Arca::Collector
3
+ include Announcements
4
+
5
+ before_save :set_title, :set_body
6
+ before_save :upcase_title, :if => :title_is_a_shout?
7
+
8
+ def set_title
9
+ self.title ||= "Ticket id #{SecureRandom.hex(2)}"
10
+ end
11
+
12
+ def set_body
13
+ self.body ||= "Everything is broken."
14
+ end
15
+
16
+ def upcase_title
17
+ self.title = title.upcase
18
+ end
19
+
20
+ def title_is_a_shout?
21
+ self.title.split(" ").size == 1
22
+ end
23
+ end
@@ -0,0 +1,133 @@
1
+ require_relative "../../test_helper"
2
+
3
+ class Arca::CallbackAnalysisTest < Minitest::Test
4
+ def model
5
+ @model ||= Arca::Model.new(Ticket)
6
+ end
7
+
8
+ def announce_save
9
+ @announce_save ||= Arca::CallbackAnalysis.new({
10
+ :model => model,
11
+ :callback_data => {
12
+ :callback_symbol => :after_save,
13
+ :callback_file_path => "/Users/jonmagic/Projects/arca/test/fixtures/announcements.rb",
14
+ :callback_line_number => 4,
15
+ :target_symbol => :announce_save,
16
+ :conditional_symbol => nil,
17
+ :conditional_target_symbol => nil
18
+ }
19
+ })
20
+ end
21
+
22
+ def set_title
23
+ @set_title ||= Arca::CallbackAnalysis.new({
24
+ :model => model,
25
+ :callback_data => {
26
+ :callback_symbol=>:before_save,
27
+ :callback_file_path => "/Users/jonmagic/Projects/arca/test/fixtures/ticket.rb",
28
+ :callback_line_number => 5,
29
+ :target_symbol => :set_title,
30
+ :conditional_symbol => nil,
31
+ :conditional_target_symbol => nil
32
+ }
33
+ })
34
+ end
35
+
36
+ def upcase_title
37
+ @upcase_title ||= Arca::CallbackAnalysis.new({
38
+ :model => model,
39
+ :callback_data => {
40
+ :callback_symbol => :before_save,
41
+ :callback_file_path => "/Users/jonmagic/Projects/arca/test/fixtures/ticket.rb",
42
+ :callback_line_number => 6,
43
+ :target_symbol => :upcase_title,
44
+ :conditional_symbol => :if,
45
+ :conditional_target_symbol => :title_is_a_shout?
46
+ }
47
+ })
48
+ end
49
+
50
+ def test_callback_symbol
51
+ assert_equal :after_save, announce_save.callback_symbol
52
+ assert_equal :before_save, set_title.callback_symbol
53
+ assert_equal :before_save, upcase_title.callback_symbol
54
+ end
55
+
56
+ def test_callback_file_path
57
+ assert_match "test/fixtures/announcements.rb", announce_save.callback_file_path
58
+ assert_match "test/fixtures/ticket.rb", set_title.callback_file_path
59
+ assert_match "test/fixtures/ticket.rb", upcase_title.callback_file_path
60
+ end
61
+
62
+ def test_callback_line_number
63
+ assert_equal 4, announce_save.callback_line_number
64
+ assert_equal 5, set_title.callback_line_number
65
+ assert_equal 6, upcase_title.callback_line_number
66
+ end
67
+
68
+ def test_external_callback?
69
+ assert_predicate announce_save, :external_callback?
70
+ refute_predicate set_title, :external_callback?
71
+ refute_predicate upcase_title, :external_callback?
72
+ end
73
+
74
+ def test_target_symbol
75
+ assert_equal :announce_save, announce_save.target_symbol
76
+ assert_equal :set_title, set_title.target_symbol
77
+ assert_equal :upcase_title, upcase_title.target_symbol
78
+ end
79
+
80
+ def test_target_file_path
81
+ assert_match "test/fixtures/announcements.rb", announce_save.target_file_path
82
+ assert_match "test/fixtures/ticket.rb", set_title.target_file_path
83
+ assert_match "test/fixtures/ticket.rb", upcase_title.target_file_path
84
+ end
85
+
86
+ def test_target_line_number
87
+ assert_equal 8, announce_save.target_line_number
88
+ assert_equal 8, set_title.target_line_number
89
+ assert_equal 16, upcase_title.target_line_number
90
+ end
91
+
92
+ def test_external_target?
93
+ refute_predicate announce_save, :external_target?
94
+ refute_predicate set_title, :external_target?
95
+ refute_predicate upcase_title, :external_target?
96
+ end
97
+
98
+ def test_lines_to_target
99
+ assert_equal 4, announce_save.lines_to_target
100
+ assert_equal 3, set_title.lines_to_target
101
+ assert_equal 10, upcase_title.lines_to_target
102
+ end
103
+
104
+ def test_conditional_symbol
105
+ assert_nil announce_save.conditional_symbol
106
+ assert_nil set_title.conditional_symbol
107
+ assert_equal :if, upcase_title.conditional_symbol
108
+ end
109
+
110
+ def test_conditional_target_symbol
111
+ assert_nil announce_save.conditional_target_symbol
112
+ assert_nil set_title.conditional_target_symbol
113
+ assert_equal :title_is_a_shout?, upcase_title.conditional_target_symbol
114
+ end
115
+
116
+ def test_conditional_target_file_path
117
+ assert_nil announce_save.conditional_target_file_path
118
+ assert_nil set_title.conditional_target_file_path
119
+ assert_match "test/fixtures/ticket.rb", upcase_title.conditional_target_file_path
120
+ end
121
+
122
+ def test_conditional_target_line_number
123
+ assert_nil announce_save.conditional_target_line_number
124
+ assert_nil set_title.conditional_target_line_number
125
+ assert_equal 20, upcase_title.conditional_target_line_number
126
+ end
127
+
128
+ def test_lines_to_conditional_target
129
+ assert_nil announce_save.lines_to_conditional_target
130
+ assert_nil set_title.lines_to_conditional_target
131
+ assert_equal 14, upcase_title.lines_to_conditional_target
132
+ end
133
+ end
@@ -0,0 +1,66 @@
1
+ require_relative "../../test_helper"
2
+
3
+ class Arca::CollectorTest < Minitest::Test
4
+ def test_collects_callback_data
5
+ callbacks = Ticket.arca_callback_data
6
+
7
+ callback = callbacks[:after_save][0]
8
+ assert_equal :after_save, callback[:callback_symbol]
9
+ assert_match "test/fixtures/announcements.rb", callback[:callback_file_path]
10
+ assert_equal 4, callback[:callback_line_number]
11
+ assert_equal :announce_save, callback[:target_symbol]
12
+ assert_nil callback[:conditional_symbol]
13
+ assert_nil callback[:conditional_target_symbol]
14
+
15
+ callback = callbacks[:before_save][0]
16
+ assert_equal :before_save, callback[:callback_symbol]
17
+ assert_match "test/fixtures/ticket.rb", callback[:callback_file_path]
18
+ assert_equal 5, callback[:callback_line_number]
19
+ assert_equal :set_title, callback[:target_symbol]
20
+ assert_nil callback[:conditional_symbol]
21
+ assert_nil callback[:conditional_target_symbol]
22
+
23
+ callback = callbacks[:before_save][1]
24
+ assert_equal :before_save, callback[:callback_symbol]
25
+ assert_match "test/fixtures/ticket.rb", callback[:callback_file_path]
26
+ assert_equal 5, callback[:callback_line_number]
27
+ assert_equal :set_body, callback[:target_symbol]
28
+ assert_nil callback[:conditional_symbol]
29
+ assert_nil callback[:conditional_target_symbol]
30
+
31
+ callback = callbacks[:before_save][2]
32
+ assert_equal :before_save, callback[:callback_symbol]
33
+ assert_match "test/fixtures/ticket.rb", callback[:callback_file_path]
34
+ assert_equal 6, callback[:callback_line_number]
35
+ assert_equal :upcase_title, callback[:target_symbol]
36
+ assert_equal :if, callback[:conditional_symbol]
37
+ assert_equal :title_is_a_shout?, callback[:conditional_target_symbol]
38
+ end
39
+
40
+ def test_arca_model_root_path_is_required
41
+ model_root_path = Arca.model_root_path
42
+ Arca.instance_variable_set(:@model_root_path, nil)
43
+
44
+ assert_raises(Arca::Collector::ModelRootPathRequired) do
45
+ require_relative "../../fixtures/bar"
46
+ end
47
+
48
+ Arca.model_root_path = model_root_path
49
+ end
50
+
51
+ def test_callback_is_reapplied_with_original_args
52
+ foo = Foo.new
53
+ refute foo.boop?
54
+ foo.save
55
+ assert foo.mission_complete
56
+ assert_nil foo.bargo
57
+ assert_nil foo.bazinga
58
+
59
+ foo.boop = true
60
+ assert foo.boop?
61
+ foo.save
62
+ assert foo.mission_complete
63
+ assert_equal "hello", foo.bargo
64
+ assert_equal "world", foo.bazinga
65
+ end
66
+ end
@@ -0,0 +1,59 @@
1
+ require_relative "../../test_helper"
2
+
3
+ class Arca::ModelTest < Minitest::Test
4
+ def model
5
+ @model ||= Arca::Model.new(Ticket)
6
+ end
7
+
8
+ def test_report
9
+ assert model.report.instance_of?(Arca::Report)
10
+ end
11
+
12
+ def test_source_location
13
+ source_location = model.source_location(:set_title)
14
+ assert_match "test/fixtures/ticket.rb", source_location[:file_path]
15
+ assert_equal 8, source_location[:line_number]
16
+ end
17
+
18
+ def test_source_location_with_method_symbol_with_no_associated_method
19
+ source_location = model.source_location(:foo_bar)
20
+ assert_nil source_location[:file_path]
21
+ assert_nil source_location[:line_number]
22
+ end
23
+
24
+ def test_source_location_with_an_invalid_object
25
+ source_location = model.source_location(lambda {})
26
+ assert_nil source_location[:file_path]
27
+ assert_nil source_location[:line_number]
28
+ end
29
+
30
+ def test_analyzed_callbacks
31
+ assert_equal 3, model.analyzed_callbacks[:before_save].size
32
+ assert_equal 1, model.analyzed_callbacks[:after_save].size
33
+ end
34
+
35
+ def test_analyzed_callbacks_array
36
+ assert_equal 4, model.analyzed_callbacks_array.size
37
+ assert model.analyzed_callbacks_array[0].is_a?(Arca::CallbackAnalysis)
38
+ end
39
+
40
+ def test_analyzed_callbacks_count
41
+ assert_equal 4, model.analyzed_callbacks_count
42
+ end
43
+
44
+ def test_lines_between_count
45
+ assert_equal 6, model.lines_between_count
46
+ end
47
+
48
+ def test_external_callbacks_count
49
+ assert_equal 1, model.external_callbacks_count
50
+ end
51
+
52
+ def test_external_targets_count
53
+ assert_equal 0, model.external_targets_count
54
+ end
55
+
56
+ def test_external_conditionals_count
57
+ assert_equal 0, model.external_conditionals_count
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ require_relative "../../test_helper"
2
+
3
+ class Arca::ReportTest < Minitest::Test
4
+ def report
5
+ @report ||= Arca::Report.new(Arca::Model.new(Ticket))
6
+ end
7
+
8
+ def test_model_class
9
+ assert_equal "Ticket", report.model_name
10
+ end
11
+
12
+ def test_model_file_path
13
+ assert_match "test/fixtures/ticket.rb", report.model_file_path
14
+ end
15
+
16
+ def callbacks_count
17
+ assert_equal 4, report.callbacks_count
18
+ end
19
+
20
+ def test_conditionals_count
21
+ assert_equal 1, report.conditionals_count
22
+ end
23
+
24
+ def test_calculated_permutations
25
+ assert_equal 2, report.calculated_permutations
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "../test_helper"
2
+
3
+ class ArcaTest < Minitest::Test
4
+ def test_returns_model
5
+ assert Arca[Ticket].instance_of?(Arca::Model)
6
+ end
7
+
8
+ def test_requires_class
9
+ assert_raises(Arca::ClassRequired) do
10
+ Arca[:ticket]
11
+ end
12
+ end
13
+
14
+ def test_requires_callback_data
15
+ assert_raises(Arca::CallbackDataMissing) do
16
+ Arca[ArcaTest]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
2
+ require "active_record"
3
+ require "minitest/autorun"
4
+
5
+ require "arca"
6
+ Arca.root_path = `pwd`.chomp
7
+ Arca.model_root_path = Arca.root_path + "/test/fixtures"
8
+
9
+ require_relative "fixtures/announcements"
10
+ require_relative "fixtures/ticket"
11
+ require_relative "fixtures/foo"
12
+
13
+ if ENV["CONSOLE"]
14
+ require "pry"
15
+ binding.pry
16
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: arca
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Hoyt
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-07-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '10.4'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '10.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.10'
55
+ description: Arca is a callback analyzer for ActiveRecord ideally suited for digging
56
+ yourself out of callback hell
57
+ email: jonmagic@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - Gemfile
63
+ - Gemfile.lock
64
+ - README.md
65
+ - Rakefile
66
+ - arca.gemspec
67
+ - lib/arca.rb
68
+ - lib/arca/callback_analysis.rb
69
+ - lib/arca/collector.rb
70
+ - lib/arca/model.rb
71
+ - lib/arca/report.rb
72
+ - test/fixtures/announcements.rb
73
+ - test/fixtures/bar.rb
74
+ - test/fixtures/foo.rb
75
+ - test/fixtures/ticket.rb
76
+ - test/lib/arca/callback_analysis_test.rb
77
+ - test/lib/arca/collector_test.rb
78
+ - test/lib/arca/model_test.rb
79
+ - test/lib/arca/report_test.rb
80
+ - test/lib/arca_test.rb
81
+ - test/test_helper.rb
82
+ homepage: https://github.com/jonmagic/arca
83
+ licenses:
84
+ - MIT
85
+ metadata: {}
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubyforge_project:
102
+ rubygems_version: 2.2.2
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: ActiveRecord callback analyzer
106
+ test_files:
107
+ - test/fixtures/announcements.rb
108
+ - test/fixtures/bar.rb
109
+ - test/fixtures/foo.rb
110
+ - test/fixtures/ticket.rb
111
+ - test/lib/arca/callback_analysis_test.rb
112
+ - test/lib/arca/collector_test.rb
113
+ - test/lib/arca/model_test.rb
114
+ - test/lib/arca/report_test.rb
115
+ - test/lib/arca_test.rb
116
+ - test/test_helper.rb