tapping_device 0.4.9 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
@@ -2,9 +2,14 @@ require "active_record"
2
2
  require "tapping_device/version"
3
3
  require "tapping_device/manageable"
4
4
  require "tapping_device/payload"
5
+ require "tapping_device/output_payload"
5
6
  require "tapping_device/trackable"
6
7
  require "tapping_device/exceptions"
7
- require "tapping_device/sql_tapping_methods"
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
 
@@ -16,7 +21,6 @@ class TappingDevice
16
21
  @devices = []
17
22
  @suspend_new = false
18
23
 
19
- include SqlTappingMethods
20
24
  extend Manageable
21
25
 
22
26
  def initialize(options = {}, &block)
@@ -25,29 +29,25 @@ class TappingDevice
25
29
  @options = process_options(options)
26
30
  @calls = []
27
31
  @disabled = false
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?)
34
- end
35
-
36
- def tap_on!(object)
37
- track(object, condition: :tap_on?)
38
- end
39
-
40
- def tap_passed!(object)
41
- track(object, condition: :tap_passed?)
42
- end
32
+ @with_condition = nil
33
+ TappingDevice.devices << self
34
+ end
35
+
36
+ def and_print(payload_method = nil, &block)
37
+ @output_block =
38
+ if block
39
+ -> (output_payload) { puts(block.call(output_payload)) }
40
+ elsif payload_method
41
+ -> (output_payload) { puts(output_payload.send(payload_method)) }
42
+ else
43
+ raise "need to provide either a payload method name or a block"
44
+ end
43
45
 
44
- def tap_assoc!(record)
45
- raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
46
- track(record, condition: :tap_associations?)
46
+ self
47
47
  end
48
48
 
49
- def and_print(payload_method)
50
- @output_block = -> (payload) { puts(payload.send(payload_method)) }
49
+ def with(&block)
50
+ @with_condition = block
51
51
  end
52
52
 
53
53
  def set_block(&block)
@@ -56,7 +56,7 @@ class TappingDevice
56
56
 
57
57
  def stop!
58
58
  @disabled = true
59
- self.class.delete_device(self)
59
+ TappingDevice.delete_device(self)
60
60
  end
61
61
 
62
62
  def stop_when(&block)
@@ -79,48 +79,42 @@ class TappingDevice
79
79
  options[:descendants]
80
80
  end
81
81
 
82
- private
83
-
84
- def track(object, condition:)
82
+ def track(object)
85
83
  @target = object
86
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
87
- if send(condition, object, tp)
88
- filepath, line_number = get_call_location(tp)
89
-
90
- next if should_be_skipped_by_paths?(filepath)
91
-
92
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
84
+ validate_target!
93
85
 
94
- record_call!(payload)
86
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
87
+ record_call!(payload)
95
88
 
96
- stop_if_condition_fulfilled(payload)
97
- end
89
+ stop_if_condition_fulfilled!(payload)
98
90
  end
99
91
 
100
- @trace_point.enable unless self.class.suspend_new
92
+ @trace_point.enable unless TappingDevice.suspend_new
101
93
 
102
94
  self
103
95
  end
104
96
 
105
- def get_call_location(tp, padding: 0)
106
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
107
- end
97
+ private
108
98
 
109
- def get_trace_index(tp)
110
- if tp.event == :c_call
111
- C_CALLER_START_POINT
112
- else
113
- CALLER_START_POINT
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)
103
+
104
+ filepath, line_number = get_call_location(tp)
105
+ payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
106
+
107
+ next if should_be_skipped_by_paths?(filepath)
108
+ next unless with_condition_satisfied?(payload)
109
+
110
+ yield(payload)
114
111
  end
115
112
  end
116
113
 
117
- def get_traces(tp)
118
- if with_trace_to = options[:with_trace_to]
119
- trace_index = get_trace_index(tp)
120
- caller[trace_index..(trace_index + with_trace_to)]
121
- else
122
- []
123
- end
114
+ def validate_target!; end
115
+
116
+ def filter_condition_satisfied?(tp)
117
+ false
124
118
  end
125
119
 
126
120
  # this needs to be placed upfront so we can exclude noise before doing more work
@@ -129,16 +123,29 @@ class TappingDevice
129
123
  (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
130
124
  end
131
125
 
132
- def build_payload(tp:, filepath:, line_number:)
133
- arguments = {}
134
- tp.binding.local_variables.each { |name| arguments[name] = tp.binding.local_variable_get(name) }
126
+ def is_tapping_device_call?(tp)
127
+ if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
128
+ return true
129
+ end
135
130
 
131
+ if Module.respond_to?(:module_parents)
132
+ tp.defined_class.module_parents.include?(TappingDevice)
133
+ else
134
+ tp.defined_class.parents.include?(TappingDevice)
135
+ end
136
+ end
137
+
138
+ def with_condition_satisfied?(payload)
139
+ @with_condition.blank? || @with_condition.call(payload)
140
+ end
141
+
142
+ def build_payload(tp:, filepath:, line_number:)
136
143
  Payload.init({
137
144
  target: @target,
138
145
  receiver: tp.self,
139
146
  method_name: tp.callee_id,
140
147
  method_object: get_method_object_from(tp.self, tp.callee_id),
141
- arguments: arguments,
148
+ arguments: collect_arguments(tp),
142
149
  return_value: (tp.return_value rescue nil),
143
150
  filepath: filepath,
144
151
  line_number: line_number,
@@ -148,51 +155,6 @@ class TappingDevice
148
155
  })
149
156
  end
150
157
 
151
- def tap_init?(klass, tp)
152
- receiver = tp.self
153
- method_name = tp.callee_id
154
-
155
- if klass.ancestors.include?(ActiveRecord::Base)
156
- method_name == :new && receiver.ancestors.include?(klass)
157
- else
158
- method_name == :initialize && receiver.is_a?(klass)
159
- end
160
- end
161
-
162
- def tap_on?(object, tp)
163
- is_from_target?(object, tp)
164
- end
165
-
166
- def tap_associations?(object, tp)
167
- return false unless tap_on?(object, tp)
168
-
169
- model_class = object.class
170
- associations = model_class.reflections
171
- associations.keys.include?(tp.callee_id.to_s)
172
- end
173
-
174
- def tap_passed?(object, tp)
175
- # we don't care about calls from the device instance or helper methods
176
- return false if is_from_target?(self, tp)
177
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
178
-
179
- method_object = get_method_object_from(tp.self, tp.callee_id)
180
- return false unless method_object.is_a?(Method)
181
- # if a no-arugment method is called, tp.binding.local_variables will be those local variables in the same scope
182
- # so we need to make sure the method takes arguments, then we can be sure that the locals are arguments
183
- return false unless method_object && method_object.arity.to_i > 0
184
-
185
- argument_values = tp.binding.local_variables.map { |name| tp.binding.local_variable_get(name) }
186
-
187
- argument_values.any? do |value|
188
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
189
- # but not every value supports every conversion methods
190
- object == value rescue false
191
- end
192
- rescue
193
- false
194
- end
195
-
196
158
  def get_method_object_from(target, method_name)
197
159
  target.method(method_name)
198
160
  rescue ArgumentError
@@ -204,6 +166,40 @@ class TappingDevice
204
166
  nil
205
167
  end
206
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
+
190
+ def collect_arguments(tp)
191
+ parameters =
192
+ if RUBY_VERSION.to_f >= 2.6
193
+ tp.parameters
194
+ else
195
+ get_method_object_from(tp.self, tp.callee_id)&.parameters || []
196
+ end.map { |parameter| parameter[1] }
197
+
198
+ tp.binding.local_variables.each_with_object({}) do |name, args|
199
+ args[name] = tp.binding.local_variable_get(name) if parameters.include?(name)
200
+ end
201
+ end
202
+
207
203
  def process_options(options)
208
204
  options[:filter_by_paths] ||= []
209
205
  options[:exclude_by_paths] ||= []
@@ -215,19 +211,12 @@ class TappingDevice
215
211
  options
216
212
  end
217
213
 
218
- def stop_if_condition_fulfilled(payload)
219
- if @stop_when&.call(payload)
220
- stop!
221
- root_device.stop!
222
- end
223
- end
224
-
225
- def is_from_target?(object, tp)
214
+ def is_from_target?(tp)
226
215
  comparsion = tp.self
227
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
216
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
228
217
  end
229
218
 
230
- def is_the_same_record?(target, comparsion)
219
+ def is_the_same_record?(comparsion)
231
220
  return false unless options[:track_as_records]
232
221
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
233
222
  primary_key = target.class.primary_key
@@ -238,7 +227,7 @@ class TappingDevice
238
227
  def record_call!(payload)
239
228
  return if @disabled
240
229
 
241
- @output_block.call(payload) if @output_block
230
+ @output_block.call(OutputPayload.init(payload)) if @output_block
242
231
 
243
232
  if @block
244
233
  root_device.calls << @block.call(payload)
@@ -246,4 +235,11 @@ class TappingDevice
246
235
  root_device.calls << payload
247
236
  end
248
237
  end
238
+
239
+ def stop_if_condition_fulfilled!(payload)
240
+ if @stop_when&.call(payload)
241
+ stop!
242
+ root_device.stop!
243
+ end
244
+ end
249
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
@@ -0,0 +1,173 @@
1
+ class TappingDevice
2
+ class OutputPayload < Payload
3
+ UNDEFINED = "[undefined]"
4
+
5
+ alias :raw_arguments :arguments
6
+ alias :raw_return_value :return_value
7
+
8
+ def method_name(options = {})
9
+ ":#{super(options)}"
10
+ end
11
+
12
+ def arguments(options = {})
13
+ generate_string_result(raw_arguments, options[:inspect])
14
+ end
15
+
16
+ def return_value(options = {})
17
+ generate_string_result(raw_return_value, options[:inspect])
18
+ end
19
+
20
+ COLOR_CODES = {
21
+ green: 10,
22
+ yellow: 11,
23
+ blue: 12,
24
+ megenta: 13,
25
+ cyan: 14,
26
+ orange: 214
27
+ }
28
+
29
+ COLORS = COLOR_CODES.each_with_object({}) do |(name, code), hash|
30
+ hash[name] = "\u001b[38;5;#{code}m"
31
+ end.merge(
32
+ reset: "\u001b[0m",
33
+ nocolor: ""
34
+ )
35
+
36
+ PAYLOAD_ATTRIBUTES = {
37
+ method_name: {symbol: "", color: COLORS[:blue]},
38
+ location: {symbol: "from:", color: COLORS[:green]},
39
+ return_value: {symbol: "=>", color: COLORS[:megenta]},
40
+ arguments: {symbol: "<=", color: COLORS[:orange]},
41
+ ivar_changes: {symbol: "changes:\n", color: COLORS[:blue]},
42
+ defined_class: {symbol: "#", color: COLORS[:yellow]}
43
+ }
44
+
45
+ PAYLOAD_ATTRIBUTES.each do |attribute, attribute_options|
46
+ color = attribute_options[:color]
47
+
48
+ alias_method "original_#{attribute}".to_sym, attribute
49
+
50
+ # regenerate attributes with `colorize: true` support
51
+ define_method attribute do |options = {}|
52
+ call_result = send("original_#{attribute}", options)
53
+
54
+ if options[:colorize]
55
+ "#{color}#{call_result}#{COLORS[:reset]}"
56
+ else
57
+ call_result
58
+ end
59
+ end
60
+
61
+ define_method "#{attribute}_with_color" do |options = {}|
62
+ send(attribute, options.merge(colorize: true))
63
+ end
64
+
65
+ PAYLOAD_ATTRIBUTES.each do |and_attribute, and_attribute_options|
66
+ next if and_attribute == attribute
67
+
68
+ define_method "#{attribute}_and_#{and_attribute}" do |options = {}|
69
+ "#{send(attribute, options)} #{and_attribute_options[:symbol]} #{send(and_attribute, options)}"
70
+ end
71
+
72
+ define_method "#{attribute}_and_#{and_attribute}_with_color" do |options = {}|
73
+ send("#{attribute}_and_#{and_attribute}", options.merge(colorize: true))
74
+ end
75
+ end
76
+ end
77
+
78
+ def passed_at(options = {})
79
+ with_method_head = options.fetch(:with_method_head, false)
80
+ arg_name = raw_arguments.keys.detect { |k| raw_arguments[k] == target }
81
+
82
+ return unless arg_name
83
+
84
+ arg_name = ":#{arg_name}"
85
+ arg_name = value_with_color(arg_name, :orange) if options[:colorize]
86
+ msg = "Passed as #{arg_name} in '#{defined_class(options)}##{method_name(options)}' at #{location(options)}"
87
+ msg += "\n > #{method_head.strip}" if with_method_head
88
+ msg
89
+ end
90
+
91
+ def detail_call_info(options = {})
92
+ <<~MSG
93
+ #{method_name_and_defined_class(options)}
94
+ from: #{location(options)}
95
+ <= #{arguments(options)}
96
+ => #{return_value(options)}
97
+
98
+ MSG
99
+ end
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
+
126
+ private
127
+
128
+ def value_with_color(value, color)
129
+ "#{COLORS[color]}#{value}#{COLORS[:reset]}"
130
+ end
131
+
132
+ def generate_string_result(obj, inspect)
133
+ case obj
134
+ when Array
135
+ array_to_string(obj, inspect)
136
+ when Hash
137
+ hash_to_string(obj, inspect)
138
+ when UNDEFINED
139
+ UNDEFINED
140
+ when String
141
+ "\"#{obj}\""
142
+ when nil
143
+ "nil"
144
+ else
145
+ inspect ? obj.inspect : obj.to_s
146
+ end
147
+ end
148
+
149
+ def array_to_string(array, inspect)
150
+ elements_string = array.map do |elem|
151
+ generate_string_result(elem, inspect)
152
+ end.join(", ")
153
+ "[#{elements_string}]"
154
+ end
155
+
156
+ def hash_to_string(hash, inspect)
157
+ elements_string = hash.map do |key, value|
158
+ "#{key.to_s}: #{generate_string_result(value, inspect)}"
159
+ end.join(", ")
160
+ "{#{elements_string}}"
161
+ end
162
+
163
+ def obj_to_string(element, inspect)
164
+ to_string_method = inspect ? :inspect : :to_s
165
+
166
+ if !inspect && element.is_a?(String)
167
+ "\"#{element}\""
168
+ else
169
+ element.send(to_string_method)
170
+ end
171
+ end
172
+ end
173
+ end