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 +4 -4
- data/Gemfile.lock +7 -7
- data/README.md +70 -3
- data/images/print_mutations.png +0 -0
- data/lib/tapping_device.rb +75 -114
- data/lib/tapping_device/exceptions.rb +12 -0
- data/lib/tapping_device/output_payload.rb +32 -4
- data/lib/tapping_device/payload.rb +1 -1
- data/lib/tapping_device/trackable.rb +35 -13
- data/lib/tapping_device/trackers/association_call_tracker.rb +17 -0
- data/lib/tapping_device/trackers/initialization_tracker.rb +27 -0
- data/lib/tapping_device/trackers/method_call_tracker.rb +9 -0
- data/lib/tapping_device/trackers/mutation_tracker.rb +132 -0
- data/lib/tapping_device/trackers/passed_tracker.rb +16 -0
- data/lib/tapping_device/version.rb +1 -1
- data/tapping_device.gemspec +2 -1
- metadata +22 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fbf652959b6f125f5e4ece39c59db0b8ebd55096e4aa639452de306857193bf7
|
4
|
+
data.tar.gz: 31d13f1f4f38b6009795e4d948ba4596428355cb03d113603b3801c307c14a18
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 98eae30679e7e08f7607dbd7ee457b9db9565e46ddf2859a782f2b5ce817bf014937b65f8ee0f342bb7d6dbec3afcb3aef980e396362726839c4031db03cb2e5
|
7
|
+
data.tar.gz: bf2d0e6e8e38466f928d57580edf745b1ee1ce18ed1ee03968dc76647b67cfb4e0feaeff0166ac7503c295c54351445344f828a0bc3878b57a74ed8a130a03cb
|
data/Gemfile.lock
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
tapping_device (0.5.
|
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.
|
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.
|
30
|
+
method_source (1.0.0)
|
30
31
|
minitest (5.14.1)
|
31
|
-
pry (0.
|
32
|
-
coderay (~> 1.1
|
33
|
-
method_source (~>
|
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
|
-
####
|
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`
|
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`
|
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
|
+
|
Binary file
|
data/lib/tapping_device.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
99
|
-
|
100
|
-
def track(object, condition:, &payload_block)
|
82
|
+
def track(object)
|
101
83
|
@target = object
|
102
|
-
|
103
|
-
|
104
|
-
|
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
|
-
|
89
|
+
stop_if_condition_fulfilled!(payload)
|
90
|
+
end
|
91
|
+
|
92
|
+
@trace_point.enable unless TappingDevice.suspend_new
|
107
93
|
|
108
|
-
|
94
|
+
self
|
95
|
+
end
|
109
96
|
|
110
|
-
|
97
|
+
private
|
111
98
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
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
|
-
|
104
|
+
filepath, line_number = get_call_location(tp)
|
105
|
+
payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
|
120
106
|
|
121
|
-
|
122
|
-
|
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
|
-
|
114
|
+
def validate_target!; end
|
126
115
|
|
127
|
-
|
116
|
+
def filter_condition_satisfied?(tp)
|
117
|
+
false
|
128
118
|
end
|
129
119
|
|
130
|
-
|
131
|
-
|
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
|
135
|
-
if tp.
|
136
|
-
|
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
|
-
|
143
|
-
|
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
|
-
|
152
|
-
|
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
|
-
|
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
|
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?(
|
216
|
+
is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
|
259
217
|
end
|
260
218
|
|
261
|
-
def is_the_same_record?(
|
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
|
282
|
-
|
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, :
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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(
|
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(
|
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
|
-
|
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(
|
40
|
+
output_payload.detail_call_info(output_options)
|
29
41
|
end
|
30
42
|
end
|
31
43
|
|
32
|
-
def
|
33
|
-
|
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,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
|
data/tapping_device.gemspec
CHANGED
@@ -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.
|
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-
|
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
|