tapping_device 0.5.1 → 0.5.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b44a9cf6d9eb0e17b965c87b38f5c431058c283c2a6b79dcce1d2b88aafcc5e
4
- data.tar.gz: 6afad055a9606958c47534cfa68394bc859c465300ac2c3f9959335edc0b7f4d
3
+ metadata.gz: fbf652959b6f125f5e4ece39c59db0b8ebd55096e4aa639452de306857193bf7
4
+ data.tar.gz: 31d13f1f4f38b6009795e4d948ba4596428355cb03d113603b3801c307c14a18
5
5
  SHA512:
6
- metadata.gz: e36715dc9d885af5b143ace2b5c3af6ff782e49d371e4b24787ac475f58d140abbad1a9730769722a59c2e06240a3a72930d9588f488595eaa6dc75973a17a31
7
- data.tar.gz: e3bd4a46af607281dc82e6bb4e3f57ad54f600d99a25892d7914c4e5577dcf8a3dc212d60449e9ced5b1789680eead0f79f3758ef6b06c25b1b7647f52e2282e
6
+ metadata.gz: 98eae30679e7e08f7607dbd7ee457b9db9565e46ddf2859a782f2b5ce817bf014937b65f8ee0f342bb7d6dbec3afcb3aef980e396362726839c4031db03cb2e5
7
+ data.tar.gz: bf2d0e6e8e38466f928d57580edf745b1ee1ce18ed1ee03968dc76647b67cfb4e0feaeff0166ac7503c295c54351445344f828a0bc3878b57a74ed8a130a03cb
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- tapping_device (0.5.1)
4
+ tapping_device (0.5.2)
5
5
  activerecord (>= 5.2)
6
+ pry
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
@@ -18,7 +19,7 @@ GEM
18
19
  minitest (~> 5.1)
19
20
  tzinfo (~> 1.1)
20
21
  zeitwerk (~> 2.2, >= 2.2.2)
21
- coderay (1.1.2)
22
+ coderay (1.1.3)
22
23
  concurrent-ruby (1.1.6)
23
24
  database_cleaner (1.7.0)
24
25
  diff-lcs (1.3)
@@ -26,11 +27,11 @@ GEM
26
27
  i18n (1.8.3)
27
28
  concurrent-ruby (~> 1.0)
28
29
  json (2.3.0)
29
- method_source (0.9.2)
30
+ method_source (1.0.0)
30
31
  minitest (5.14.1)
31
- pry (0.12.2)
32
- coderay (~> 1.1.0)
33
- method_source (~> 0.9.0)
32
+ pry (0.13.1)
33
+ coderay (~> 1.1)
34
+ method_source (~> 1.0)
34
35
  rake (13.0.1)
35
36
  rspec (3.8.0)
36
37
  rspec-core (~> 3.8.0)
@@ -62,7 +63,6 @@ PLATFORMS
62
63
  DEPENDENCIES
63
64
  bundler (~> 2.0)
64
65
  database_cleaner
65
- pry
66
66
  rake (~> 13.0)
67
67
  rspec (~> 3.0)
68
68
  simplecov (= 0.17.1)
data/README.md CHANGED
@@ -10,16 +10,17 @@
10
10
  ## Introduction
11
11
  `TappingDevice` makes the objects tell you what they do, so you don't need to track them yourself.
12
12
 
13
- #### Contract Tracing For Objects
13
+ #### Contact Tracing For Objects
14
14
 
15
15
  The concept is very simple. It's basically like [contact tracing](https://en.wikipedia.org/wiki/Contact_tracing) for your Ruby objects. You can use
16
16
 
17
17
  - `print_calls(object)` to see what the object does
18
18
  - `print_traces(object)` to see how the object interacts with other objects (like used as an argument)
19
+ - `print_mutations(object)` to see what actions changed the object's state (instance variables)
19
20
 
20
21
  Still sounds vague? Let's see some examples:
21
22
 
22
- ### `print_calls` To Track Method Calls
23
+ ### `print_calls` - Track Method Calls
23
24
 
24
25
  In [Discourse](https://github.com/discourse/discourse), it uses the `Guardian` class for authorization (like policy objects). It's barely visible in controller actions, but it does many checks under the hood. Now, let's say we want to know what the `Guardian` would do when a user creates a post; here's the controller action:
25
26
 
@@ -81,7 +82,7 @@ Let's take a closer look at each entry. Everyone of them contains the method cal
81
82
  These are the information you'd have to look up one by one manually (probably with many debug code writing). Now you can get all of them in just one line of code.
82
83
 
83
84
 
84
- ### `print_traces` To See The Object's Traces
85
+ ### `print_traces` - See The Object's Traces
85
86
 
86
87
  If you're not interested in what an object does, but what it interacts with other parts of the program, e.g., used as arguments. You can use the `print_traces` helper. Let's see how `Discourse` uses the `manager` object when creating a post
87
88
 
@@ -106,6 +107,71 @@ You will see that it performs 2 calls: `perform` and `perform_create_post`. And
106
107
 
107
108
  ![image of print_traces output](https://github.com/st0012/tapping_device/blob/master/images/print_traces.png)
108
109
 
110
+ ### `print_mutations` - Display All State Changes At Once
111
+
112
+ Another thing that often bothers developers in debugging is to track an object's internal state changes. And `tapping_device` allows you to see all state changes with just one line of code. Let me keep using [Discourse](https://github.com/discourse/discourse) to demonstrate it.
113
+
114
+ When updating a post, it uses an object called `PostRevisor` to revise it:
115
+
116
+ ```ruby
117
+ # app/controllers/posts_controller.rb
118
+ class PostsController
119
+ def update
120
+ # ......
121
+ revisor = PostRevisor.new(post, topic)
122
+ revisor.revise!(current_user, changes, opts)
123
+ # ......
124
+ end
125
+ end
126
+ ```
127
+
128
+ In the `PostReviser#revise!`, it uses many instance variables to track different information:
129
+
130
+ ```ruby
131
+ # lib/post_revisor.rb
132
+ def revise!(editor, fields, opts = {})
133
+ @editor = editor
134
+ @fields = fields.with_indifferent_access
135
+ @opts = opts
136
+
137
+ @topic_changes = TopicChanges.new(@topic, editor)
138
+
139
+ # ......
140
+
141
+ @revised_at = @opts[:revised_at] || Time.now
142
+ @last_version_at = @post.last_version_at || Time.now
143
+
144
+ @version_changed = false
145
+ @post_successfully_saved = true
146
+
147
+ @validate_post = true
148
+ # ......
149
+ end
150
+ ```
151
+
152
+ Tracking the changes of that many instance variables can be a painful task, especially when we want to know the values before and after certain method call. This is why I created `print_mutations` to save us from this.
153
+
154
+ Like other helpers, you only need 1 line of code
155
+
156
+ ```ruby
157
+ # app/controllers/posts_controller.rb
158
+ class PostsController
159
+ def update
160
+ # ......
161
+ revisor = PostRevisor.new(post, topic)
162
+ print_mutations(revisor)
163
+ revisor.revise!(current_user, changes, opts)
164
+ # ......
165
+ end
166
+ end
167
+ ```
168
+
169
+ And then you'll see all the state changes:
170
+
171
+ <img src="https://github.com/st0012/tapping_device/blob/master/images/print_mutations.png" alt="image of print_mutations output" width="50%">
172
+
173
+ Now you can see what method changes which states. And more importantly, you get to see all the sate changes at once!
174
+
109
175
  **You can try these examples on [my fork of discourse](https://github.com/st0012/discourse/tree/demo-for-tapping-device)**
110
176
 
111
177
 
@@ -190,3 +256,4 @@ The gem is available as open-source under the terms of the [MIT License](https:/
190
256
  ## Code of Conduct
191
257
 
192
258
  Everyone interacting in the TappingDevice project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/tapping_device/blob/master/CODE_OF_CONDUCT.md).
259
+
@@ -5,6 +5,11 @@ require "tapping_device/payload"
5
5
  require "tapping_device/output_payload"
6
6
  require "tapping_device/trackable"
7
7
  require "tapping_device/exceptions"
8
+ require "tapping_device/trackers/initialization_tracker"
9
+ require "tapping_device/trackers/passed_tracker"
10
+ require "tapping_device/trackers/association_call_tracker"
11
+ require "tapping_device/trackers/method_call_tracker"
12
+ require "tapping_device/trackers/mutation_tracker"
8
13
 
9
14
  class TappingDevice
10
15
 
@@ -25,28 +30,7 @@ class TappingDevice
25
30
  @calls = []
26
31
  @disabled = false
27
32
  @with_condition = nil
28
- self.class.devices << self
29
- end
30
-
31
- def tap_init!(klass)
32
- raise "argument should be a class, got #{klass}" unless klass.is_a?(Class)
33
- track(klass, condition: :tap_init?) do |payload|
34
- payload[:return_value] = payload[:receiver]
35
- payload[:receiver] = klass
36
- end
37
- end
38
-
39
- def tap_on!(object)
40
- track(object, condition: :tap_on?)
41
- end
42
-
43
- def tap_passed!(object)
44
- track(object, condition: :tap_passed?)
45
- end
46
-
47
- def tap_assoc!(record)
48
- raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
49
- track(record, condition: :tap_associations?)
33
+ TappingDevice.devices << self
50
34
  end
51
35
 
52
36
  def and_print(payload_method = nil, &block)
@@ -72,7 +56,7 @@ class TappingDevice
72
56
 
73
57
  def stop!
74
58
  @disabled = true
75
- self.class.delete_device(self)
59
+ TappingDevice.delete_device(self)
76
60
  end
77
61
 
78
62
  def stop_when(&block)
@@ -95,67 +79,68 @@ class TappingDevice
95
79
  options[:descendants]
96
80
  end
97
81
 
98
- private
99
-
100
- def track(object, condition:, &payload_block)
82
+ def track(object)
101
83
  @target = object
102
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
103
- if send(condition, object, tp)
104
- filepath, line_number = get_call_location(tp)
84
+ validate_target!
85
+
86
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
87
+ record_call!(payload)
105
88
 
106
- next if should_be_skipped_by_paths?(filepath)
89
+ stop_if_condition_fulfilled!(payload)
90
+ end
91
+
92
+ @trace_point.enable unless TappingDevice.suspend_new
107
93
 
108
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number, &payload_block)
94
+ self
95
+ end
109
96
 
110
- next unless with_condition_satisfied?(payload)
97
+ private
111
98
 
112
- # skip TappingDevice related calls
113
- if Module.respond_to?(:module_parents)
114
- next if payload.defined_class.module_parents.include?(TappingDevice)
115
- else
116
- next if payload.defined_class.parents.include?(TappingDevice)
117
- end
99
+ def build_minimum_trace_point(event_type:)
100
+ TracePoint.new(event_type) do |tp|
101
+ next unless filter_condition_satisfied?(tp)
102
+ next if is_tapping_device_call?(tp)
118
103
 
119
- record_call!(payload)
104
+ filepath, line_number = get_call_location(tp)
105
+ payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
120
106
 
121
- stop_if_condition_fulfilled(payload)
122
- end
107
+ next if should_be_skipped_by_paths?(filepath)
108
+ next unless with_condition_satisfied?(payload)
109
+
110
+ yield(payload)
123
111
  end
112
+ end
124
113
 
125
- @trace_point.enable unless self.class.suspend_new
114
+ def validate_target!; end
126
115
 
127
- self
116
+ def filter_condition_satisfied?(tp)
117
+ false
128
118
  end
129
119
 
130
- def get_call_location(tp, padding: 0)
131
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
120
+ # this needs to be placed upfront so we can exclude noise before doing more work
121
+ def should_be_skipped_by_paths?(filepath)
122
+ options[:exclude_by_paths].any? { |pattern| pattern.match?(filepath) } ||
123
+ (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
132
124
  end
133
125
 
134
- def get_trace_index(tp)
135
- if tp.event == :c_call
136
- C_CALLER_START_POINT
137
- else
138
- CALLER_START_POINT
126
+ def is_tapping_device_call?(tp)
127
+ if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
128
+ return true
139
129
  end
140
- end
141
130
 
142
- def get_traces(tp)
143
- if with_trace_to = options[:with_trace_to]
144
- trace_index = get_trace_index(tp)
145
- caller[trace_index..(trace_index + with_trace_to)]
131
+ if Module.respond_to?(:module_parents)
132
+ tp.defined_class.module_parents.include?(TappingDevice)
146
133
  else
147
- []
134
+ tp.defined_class.parents.include?(TappingDevice)
148
135
  end
149
136
  end
150
137
 
151
- # this needs to be placed upfront so we can exclude noise before doing more work
152
- def should_be_skipped_by_paths?(filepath)
153
- options[:exclude_by_paths].any? { |pattern| pattern.match?(filepath) } ||
154
- (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
138
+ def with_condition_satisfied?(payload)
139
+ @with_condition.blank? || @with_condition.call(payload)
155
140
  end
156
141
 
157
142
  def build_payload(tp:, filepath:, line_number:)
158
- payload = Payload.init({
143
+ Payload.init({
159
144
  target: @target,
160
145
  receiver: tp.self,
161
146
  method_name: tp.callee_id,
@@ -168,47 +153,6 @@ class TappingDevice
168
153
  trace: get_traces(tp),
169
154
  tp: tp
170
155
  })
171
-
172
- yield(payload) if block_given?
173
-
174
- payload
175
- end
176
-
177
- def tap_init?(klass, tp)
178
- receiver = tp.self
179
- method_name = tp.callee_id
180
-
181
- if klass.ancestors.include?(ActiveRecord::Base)
182
- method_name == :new && receiver.ancestors.include?(klass)
183
- else
184
- method_name == :initialize && receiver.is_a?(klass)
185
- end
186
- end
187
-
188
- def tap_on?(object, tp)
189
- is_from_target?(object, tp)
190
- end
191
-
192
- def tap_associations?(object, tp)
193
- return false unless tap_on?(object, tp)
194
-
195
- model_class = object.class
196
- associations = model_class.reflections
197
- associations.keys.include?(tp.callee_id.to_s)
198
- end
199
-
200
- def tap_passed?(object, tp)
201
- # we don't care about calls from the device instance or helper methods
202
- return false if is_from_target?(self, tp)
203
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
204
-
205
- collect_arguments(tp).values.any? do |value|
206
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
207
- # but not every value supports every conversion methods
208
- object == value rescue false
209
- end
210
- rescue
211
- false
212
156
  end
213
157
 
214
158
  def get_method_object_from(target, method_name)
@@ -222,6 +166,27 @@ class TappingDevice
222
166
  nil
223
167
  end
224
168
 
169
+ def get_call_location(tp, padding: 0)
170
+ caller(get_trace_index(tp) + padding).first.split(":")[0..1]
171
+ end
172
+
173
+ def get_trace_index(tp)
174
+ if tp.event == :c_call
175
+ C_CALLER_START_POINT
176
+ else
177
+ CALLER_START_POINT
178
+ end
179
+ end
180
+
181
+ def get_traces(tp)
182
+ if with_trace_to = options[:with_trace_to]
183
+ trace_index = get_trace_index(tp)
184
+ caller[trace_index..(trace_index + with_trace_to)]
185
+ else
186
+ []
187
+ end
188
+ end
189
+
225
190
  def collect_arguments(tp)
226
191
  parameters =
227
192
  if RUBY_VERSION.to_f >= 2.6
@@ -246,19 +211,12 @@ class TappingDevice
246
211
  options
247
212
  end
248
213
 
249
- def stop_if_condition_fulfilled(payload)
250
- if @stop_when&.call(payload)
251
- stop!
252
- root_device.stop!
253
- end
254
- end
255
-
256
- def is_from_target?(object, tp)
214
+ def is_from_target?(tp)
257
215
  comparsion = tp.self
258
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
216
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
259
217
  end
260
218
 
261
- def is_the_same_record?(target, comparsion)
219
+ def is_the_same_record?(comparsion)
262
220
  return false unless options[:track_as_records]
263
221
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
264
222
  primary_key = target.class.primary_key
@@ -278,7 +236,10 @@ class TappingDevice
278
236
  end
279
237
  end
280
238
 
281
- def with_condition_satisfied?(payload)
282
- @with_condition.blank? || @with_condition.call(payload)
239
+ def stop_if_condition_fulfilled!(payload)
240
+ if @stop_when&.call(payload)
241
+ stop!
242
+ root_device.stop!
243
+ end
283
244
  end
284
245
  end
@@ -1,4 +1,16 @@
1
1
  class TappingDevice
2
2
  class Exception < StandardError
3
3
  end
4
+
5
+ class NotAnActiveRecordInstanceError < Exception
6
+ def initialize(object)
7
+ super("target object should be an instance of ActiveRecord::Base, got #{object}")
8
+ end
9
+ end
10
+
11
+ class NotAClassError < Exception
12
+ def initialize(object)
13
+ super("target object should be a class, got #{object}")
14
+ end
15
+ end
4
16
  end
@@ -1,5 +1,7 @@
1
1
  class TappingDevice
2
2
  class OutputPayload < Payload
3
+ UNDEFINED = "[undefined]"
4
+
3
5
  alias :raw_arguments :arguments
4
6
  alias :raw_return_value :return_value
5
7
 
@@ -15,9 +17,6 @@ class TappingDevice
15
17
  generate_string_result(raw_return_value, options[:inspect])
16
18
  end
17
19
 
18
- def self.full_color_code(code)
19
- end
20
-
21
20
  COLOR_CODES = {
22
21
  green: 10,
23
22
  yellow: 11,
@@ -37,9 +36,9 @@ class TappingDevice
37
36
  PAYLOAD_ATTRIBUTES = {
38
37
  method_name: {symbol: "", color: COLORS[:blue]},
39
38
  location: {symbol: "from:", color: COLORS[:green]},
40
- sql: {symbol: "QUERIES", color: COLORS[:nocolor]},
41
39
  return_value: {symbol: "=>", color: COLORS[:megenta]},
42
40
  arguments: {symbol: "<=", color: COLORS[:orange]},
41
+ ivar_changes: {symbol: "changes:\n", color: COLORS[:blue]},
43
42
  defined_class: {symbol: "#", color: COLORS[:yellow]}
44
43
  }
45
44
 
@@ -99,6 +98,31 @@ class TappingDevice
99
98
  MSG
100
99
  end
101
100
 
101
+ def ivar_changes(options = {})
102
+ super.map do |ivar, value_changes|
103
+ before = generate_string_result(value_changes[:before], options[:inspect])
104
+ after = generate_string_result(value_changes[:after], options[:inspect])
105
+
106
+ if options[:colorize]
107
+ ivar = "#{COLORS[:orange]}#{ivar}#{COLORS[:reset]}"
108
+ before = "#{COLORS[:blue]}#{before.to_s}#{COLORS[:reset]}"
109
+ after = "#{COLORS[:blue]}#{after.to_s}#{COLORS[:reset]}"
110
+ end
111
+
112
+ " #{ivar}: #{before.to_s} => #{after.to_s}"
113
+ end.join("\n")
114
+ end
115
+
116
+ def call_info_with_ivar_changes(options = {})
117
+ <<~MSG
118
+ #{method_name_and_defined_class(options)}
119
+ from: #{location(options)}
120
+ changes:
121
+ #{ivar_changes(options)}
122
+
123
+ MSG
124
+ end
125
+
102
126
  private
103
127
 
104
128
  def value_with_color(value, color)
@@ -111,8 +135,12 @@ class TappingDevice
111
135
  array_to_string(obj, inspect)
112
136
  when Hash
113
137
  hash_to_string(obj, inspect)
138
+ when UNDEFINED
139
+ UNDEFINED
114
140
  when String
115
141
  "\"#{obj}\""
142
+ when nil
143
+ "nil"
116
144
  else
117
145
  inspect ? obj.inspect : obj.to_s
118
146
  end
@@ -2,7 +2,7 @@ class TappingDevice
2
2
  class Payload < Hash
3
3
  ATTRS = [
4
4
  :target, :receiver, :method_name, :method_object, :arguments, :return_value, :filepath, :line_number,
5
- :defined_class, :trace, :tp, :sql
5
+ :defined_class, :trace, :tp, :ivar_changes
6
6
  ]
7
7
 
8
8
  ATTRS.each do |attr|
@@ -1,36 +1,58 @@
1
1
  class TappingDevice
2
2
  module Trackable
3
- [:tap_on!, :tap_init!, :tap_assoc!, :tap_passed!].each do |method|
4
- define_method method do |object, options = {}, &block|
5
- new_device(options, &block).send(method, object)
6
- end
3
+ def tap_init!(object, options = {}, &block)
4
+ TappingDevice::Trackers::InitializationTracker.new(options, &block).track(object)
5
+ end
6
+
7
+ def tap_passed!(object, options = {}, &block)
8
+ TappingDevice::Trackers::PassedTracker.new(options, &block).track(object)
9
+ end
10
+
11
+ def tap_assoc!(object, options = {}, &block)
12
+ TappingDevice::Trackers::AssociactionCallTracker.new(options, &block).track(object)
13
+ end
14
+
15
+ def tap_on!(object, options = {}, &block)
16
+ TappingDevice::Trackers::MethodCallTracker.new(options, &block).track(object)
17
+ end
18
+
19
+ def tap_mutation!(object, options = {}, &block)
20
+ TappingDevice::Trackers::MutationTracker.new(options, &block).track(object)
7
21
  end
8
22
 
9
23
  def print_traces(target, options = {})
24
+ output_options = extract_output_options(options)
10
25
  options[:event_type] = :call
11
- inspect = options.delete(:inspect)
12
- colorize = options.fetch(:colorize, true)
13
26
 
14
27
  device_1 = tap_on!(target, options).and_print do |output_payload|
15
- "Called #{output_payload.method_name_and_location(inspect: inspect, colorize: colorize)}"
28
+ "Called #{output_payload.method_name_and_location(output_options)}"
16
29
  end
17
30
  device_2 = tap_passed!(target, options).and_print do |output_payload|
18
- output_payload.passed_at(inspect: inspect, colorize: colorize)
31
+ output_payload.passed_at(output_options)
19
32
  end
20
33
  CollectionProxy.new([device_1, device_2])
21
34
  end
22
35
 
23
36
  def print_calls(target, options = {})
24
- inspect = options.delete(:inspect)
25
- colorize = options.fetch(:colorize, true)
37
+ output_options = extract_output_options(options)
26
38
 
27
39
  tap_on!(target, options).and_print do |output_payload|
28
- output_payload.detail_call_info(inspect: inspect, colorize: colorize)
40
+ output_payload.detail_call_info(output_options)
29
41
  end
30
42
  end
31
43
 
32
- def new_device(options, &block)
33
- TappingDevice.new(options, &block)
44
+ def print_mutations(target, options = {})
45
+ output_options = extract_output_options(options)
46
+
47
+ tap_mutation!(target, options).and_print do |output_payload|
48
+ output_payload.call_info_with_ivar_changes(output_options)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def extract_output_options(options)
55
+ {inspect: options.delete(:inspect), colorize: options.fetch(:colorize, true)}
34
56
  end
35
57
 
36
58
  class CollectionProxy
@@ -0,0 +1,17 @@
1
+ class TappingDevice
2
+ module Trackers
3
+ class AssociactionCallTracker < TappingDevice
4
+ def validate_target!
5
+ raise NotAnActiveRecordInstanceError.new(target) unless target.is_a?(ActiveRecord::Base)
6
+ end
7
+
8
+ def filter_condition_satisfied?(tp)
9
+ return false unless is_from_target?(tp)
10
+
11
+ model_class = target.class
12
+ associations = model_class.reflections
13
+ associations.keys.include?(tp.callee_id.to_s)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ class TappingDevice
2
+ module Trackers
3
+ class InitializationTracker < TappingDevice
4
+ def build_payload(tp:, filepath:, line_number:)
5
+ payload = super
6
+ payload[:return_value] = payload[:receiver]
7
+ payload[:receiver] = target
8
+ payload
9
+ end
10
+
11
+ def validate_target!
12
+ raise NotAClassError.new(target) unless target.is_a?(Class)
13
+ end
14
+
15
+ def filter_condition_satisfied?(tp)
16
+ receiver = tp.self
17
+ method_name = tp.callee_id
18
+
19
+ if target.ancestors.include?(ActiveRecord::Base)
20
+ method_name == :new && receiver.ancestors.include?(target)
21
+ else
22
+ method_name == :initialize && receiver.is_a?(target)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ class TappingDevice
2
+ module Trackers
3
+ class MethodCallTracker < TappingDevice
4
+ def filter_condition_satisfied?(tp)
5
+ is_from_target?(tp)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,132 @@
1
+ require "pry" # for using Method#source
2
+
3
+ class TappingDevice
4
+ module Trackers
5
+ class MutationTracker < TappingDevice
6
+ def initialize(options, &block)
7
+ super
8
+ @snapshot_stack = []
9
+ end
10
+
11
+ def track(object)
12
+ super
13
+ hijack_attr_writers
14
+ insert_snapshot_taking_trace_point
15
+ self
16
+ end
17
+
18
+ def stop!
19
+ super
20
+ @ivar_snapshot_trace_point.disable
21
+ end
22
+
23
+ private
24
+
25
+ # we need to snapshot instance variables at the beginning of every method call
26
+ # so we can get a correct state for the later comparison
27
+ def insert_snapshot_taking_trace_point
28
+ @ivar_snapshot_trace_point = build_minimum_trace_point(event_type: :call) do
29
+ snapshot_instance_variables
30
+ end
31
+
32
+ @ivar_snapshot_trace_point.enable unless TappingDevice.suspend_new
33
+ end
34
+
35
+ def filter_condition_satisfied?(tp)
36
+ return false unless is_from_target?(tp)
37
+
38
+ if snapshot_capturing_event?(tp)
39
+ true
40
+ else
41
+ @latest_instance_variables = target_instance_variables
42
+ @instance_variables_snapshot = @snapshot_stack.pop
43
+
44
+ @latest_instance_variables != @instance_variables_snapshot
45
+ end
46
+ end
47
+
48
+ def hijack_attr_writers
49
+ writer_methods = target.methods.grep(/\w+=/)
50
+ writer_methods.each do |method_name|
51
+ if target.method(method_name).source.match?(/attr_writer|attr_accessor/)
52
+ ivar_name = "@#{method_name.to_s.sub("=", "")}"
53
+
54
+ # need to use instance_eval to make the call site location consistent with normal methods
55
+ target.instance_eval(
56
+ <<~CODE
57
+ def #{method_name}(val)
58
+ #{ivar_name} = val
59
+ end
60
+ CODE
61
+ )
62
+ end
63
+ end
64
+ end
65
+
66
+ def build_payload(tp:, filepath:, line_number:)
67
+ payload = super
68
+
69
+ if change_capturing_event?(tp)
70
+ payload[:ivar_changes] = capture_ivar_changes
71
+ end
72
+
73
+ payload
74
+ end
75
+
76
+ def capture_ivar_changes
77
+ changes = {}
78
+
79
+ additional_keys = @latest_instance_variables.keys - @instance_variables_snapshot.keys
80
+ additional_keys.each do |key|
81
+ changes[key] = {before: OutputPayload::UNDEFINED, after: @latest_instance_variables[key]}
82
+ end
83
+
84
+ removed_keys = @instance_variables_snapshot.keys - @latest_instance_variables.keys
85
+ removed_keys.each do |key|
86
+ changes[key] = {before: @instance_variables_snapshot[key], after: OutputPayload::UNDEFINED}
87
+ end
88
+
89
+ remained_keys = @latest_instance_variables.keys - additional_keys
90
+ remained_keys.each do |key|
91
+ next if @latest_instance_variables[key] == @instance_variables_snapshot[key]
92
+ changes[key] = {before: @instance_variables_snapshot[key], after: @latest_instance_variables[key]}
93
+ end
94
+
95
+ changes
96
+ end
97
+
98
+ def snapshot_instance_variables
99
+ @snapshot_stack.push(target_instance_variables)
100
+ end
101
+
102
+ def target_instance_variables
103
+ target.instance_variables.each_with_object({}) do |ivar, hash|
104
+ hash[ivar] = target.instance_variable_get(ivar)
105
+ end
106
+ end
107
+
108
+ def snapshot_capturing_event?(tp)
109
+ tp.event == :call
110
+ end
111
+
112
+ def change_capturing_event?(tp)
113
+ !snapshot_capturing_event?(tp)
114
+ end
115
+
116
+ # belows are debugging helpers
117
+ # I'll leave them for a while in case there's a bug in the tracker
118
+ def print_snapshot_stack(tp)
119
+ puts("===== STACK - #{tp.callee_id} (#{tp.event}) =====")
120
+ puts(@snapshot_stack)
121
+ puts("================ END STACK =================")
122
+ end
123
+
124
+ def print_state_comparison
125
+ puts("###############")
126
+ puts(@latest_instance_variables)
127
+ puts(@instance_variables_snapshot)
128
+ puts("###############")
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,16 @@
1
+ class TappingDevice
2
+ module Trackers
3
+ # PassedTracker tracks calls that use the target object as an argument
4
+ class PassedTracker < TappingDevice
5
+ def filter_condition_satisfied?(tp)
6
+ collect_arguments(tp).values.any? do |value|
7
+ # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
8
+ # but not every value supports every conversion methods
9
+ target == value rescue false
10
+ end
11
+ rescue
12
+ false
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,3 +1,3 @@
1
1
  class TappingDevice
2
- VERSION = "0.5.1"
2
+ VERSION = "0.5.2"
3
3
  end
@@ -32,10 +32,11 @@ Gem::Specification.new do |spec|
32
32
  spec.add_dependency "activerecord", ">= 5.2"
33
33
  end
34
34
 
35
+ spec.add_dependency "pry" # for using Method#source in MutationTracker
36
+
35
37
  spec.add_development_dependency "sqlite3", ">= 1.3.6"
36
38
  spec.add_development_dependency "database_cleaner"
37
39
  spec.add_development_dependency "bundler", "~> 2.0"
38
- spec.add_development_dependency "pry"
39
40
  spec.add_development_dependency "rake", "~> 13.0"
40
41
  spec.add_development_dependency "rspec", "~> 3.0"
41
42
  spec.add_development_dependency "simplecov", "0.17.1"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tapping_device
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - st0012
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-06-07 00:00:00.000000000 Z
11
+ date: 2020-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
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'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: sqlite3
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -66,20 +80,6 @@ dependencies:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
82
  version: '2.0'
69
- - !ruby/object:Gem::Dependency
70
- name: pry
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: rake
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -147,6 +147,7 @@ files:
147
147
  - bin/setup
148
148
  - images/print_calls - single entry.png
149
149
  - images/print_calls.png
150
+ - images/print_mutations.png
150
151
  - images/print_traces.png
151
152
  - lib/tapping_device.rb
152
153
  - lib/tapping_device/exceptions.rb
@@ -154,6 +155,11 @@ files:
154
155
  - lib/tapping_device/output_payload.rb
155
156
  - lib/tapping_device/payload.rb
156
157
  - lib/tapping_device/trackable.rb
158
+ - lib/tapping_device/trackers/association_call_tracker.rb
159
+ - lib/tapping_device/trackers/initialization_tracker.rb
160
+ - lib/tapping_device/trackers/method_call_tracker.rb
161
+ - lib/tapping_device/trackers/mutation_tracker.rb
162
+ - lib/tapping_device/trackers/passed_tracker.rb
157
163
  - lib/tapping_device/version.rb
158
164
  - tapping_device.gemspec
159
165
  homepage: https://github.com/st0012/tapping_device