tapping_device 0.5.0 → 0.5.5

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.
@@ -1,11 +1,20 @@
1
1
  require "active_record"
2
+ require "active_support/core_ext/module/introspection"
3
+ require "pry" # for using Method#source
4
+
2
5
  require "tapping_device/version"
3
6
  require "tapping_device/manageable"
4
7
  require "tapping_device/payload"
5
- require "tapping_device/output_payload"
8
+ require "tapping_device/output"
6
9
  require "tapping_device/trackable"
10
+ require "tapping_device/configurable"
7
11
  require "tapping_device/exceptions"
8
- require "tapping_device/sql_tapping_methods"
12
+ require "tapping_device/method_hijacker"
13
+ require "tapping_device/trackers/initialization_tracker"
14
+ require "tapping_device/trackers/passed_tracker"
15
+ require "tapping_device/trackers/association_call_tracker"
16
+ require "tapping_device/trackers/method_call_tracker"
17
+ require "tapping_device/trackers/mutation_tracker"
9
18
 
10
19
  class TappingDevice
11
20
 
@@ -17,51 +26,19 @@ class TappingDevice
17
26
  @devices = []
18
27
  @suspend_new = false
19
28
 
20
- include SqlTappingMethods
21
29
  extend Manageable
22
30
 
31
+ include Configurable
32
+ include Output::Helpers
33
+
23
34
  def initialize(options = {}, &block)
24
35
  @block = block
25
36
  @output_block = nil
26
- @options = process_options(options)
37
+ @options = process_options(options.dup)
27
38
  @calls = []
28
39
  @disabled = false
29
40
  @with_condition = nil
30
- self.class.devices << self
31
- end
32
-
33
- def tap_init!(klass)
34
- raise "argument should be a class, got #{klass}" unless klass.is_a?(Class)
35
- track(klass, condition: :tap_init?) do |payload|
36
- payload[:return_value] = payload[:receiver]
37
- payload[:receiver] = klass
38
- end
39
- end
40
-
41
- def tap_on!(object)
42
- track(object, condition: :tap_on?)
43
- end
44
-
45
- def tap_passed!(object)
46
- track(object, condition: :tap_passed?)
47
- end
48
-
49
- def tap_assoc!(record)
50
- raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
51
- track(record, condition: :tap_associations?)
52
- end
53
-
54
- def and_print(payload_method = nil, &block)
55
- @output_block =
56
- if block
57
- -> (output_payload) { puts(block.call(output_payload)) }
58
- elsif payload_method
59
- -> (output_payload) { puts(output_payload.send(payload_method)) }
60
- else
61
- raise "need to provide either a payload method name or a block"
62
- end
63
-
64
- self
41
+ TappingDevice.devices << self
65
42
  end
66
43
 
67
44
  def with(&block)
@@ -74,7 +51,7 @@ class TappingDevice
74
51
 
75
52
  def stop!
76
53
  @disabled = true
77
- self.class.delete_device(self)
54
+ TappingDevice.delete_device(self)
78
55
  end
79
56
 
80
57
  def stop_when(&block)
@@ -97,50 +74,48 @@ class TappingDevice
97
74
  options[:descendants]
98
75
  end
99
76
 
100
- private
101
-
102
- def track(object, condition:, &payload_block)
77
+ def track(object)
103
78
  @target = object
104
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
105
- if send(condition, object, tp)
106
- filepath, line_number = get_call_location(tp)
107
-
108
- next if should_be_skipped_by_paths?(filepath)
79
+ validate_target!
109
80
 
110
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number, &payload_block)
81
+ MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
111
82
 
112
- next unless with_condition_satisfied?(payload)
113
-
114
- record_call!(payload)
83
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
84
+ record_call!(payload)
115
85
 
116
- stop_if_condition_fulfilled(payload)
117
- end
86
+ stop_if_condition_fulfilled!(payload)
118
87
  end
119
88
 
120
- @trace_point.enable unless self.class.suspend_new
89
+ @trace_point.enable unless TappingDevice.suspend_new
121
90
 
122
91
  self
123
92
  end
124
93
 
125
- def get_call_location(tp, padding: 0)
126
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
127
- end
94
+ private
128
95
 
129
- def get_trace_index(tp)
130
- if tp.event == :c_call
131
- C_CALLER_START_POINT
132
- else
133
- CALLER_START_POINT
96
+ def build_minimum_trace_point(event_type:)
97
+ TracePoint.new(*event_type) do |tp|
98
+ next unless filter_condition_satisfied?(tp)
99
+
100
+ filepath, line_number = get_call_location(tp)
101
+ payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
102
+
103
+ unless @options[:force_recording]
104
+ next if is_tapping_device_call?(tp)
105
+ next if should_be_skipped_by_paths?(filepath)
106
+ next unless with_condition_satisfied?(payload)
107
+ next if payload.is_private_call? && @options[:ignore_private]
108
+ next if !payload.is_private_call? && @options[:only_private]
109
+ end
110
+
111
+ yield(payload)
134
112
  end
135
113
  end
136
114
 
137
- def get_traces(tp)
138
- if with_trace_to = options[:with_trace_to]
139
- trace_index = get_trace_index(tp)
140
- caller[trace_index..(trace_index + with_trace_to)]
141
- else
142
- []
143
- end
115
+ def validate_target!; end
116
+
117
+ def filter_condition_satisfied?(tp)
118
+ false
144
119
  end
145
120
 
146
121
  # this needs to be placed upfront so we can exclude noise before doing more work
@@ -149,8 +124,24 @@ class TappingDevice
149
124
  (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
150
125
  end
151
126
 
127
+ def is_tapping_device_call?(tp)
128
+ if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
129
+ return true
130
+ end
131
+
132
+ if Module.respond_to?(:module_parents)
133
+ tp.defined_class.module_parents.include?(TappingDevice)
134
+ elsif Module.respond_to?(:parents)
135
+ tp.defined_class.parents.include?(TappingDevice)
136
+ end
137
+ end
138
+
139
+ def with_condition_satisfied?(payload)
140
+ @with_condition.blank? || @with_condition.call(payload)
141
+ end
142
+
152
143
  def build_payload(tp:, filepath:, line_number:)
153
- payload = Payload.init({
144
+ Payload.init({
154
145
  target: @target,
155
146
  receiver: tp.self,
156
147
  method_name: tp.callee_id,
@@ -161,60 +152,38 @@ class TappingDevice
161
152
  line_number: line_number,
162
153
  defined_class: tp.defined_class,
163
154
  trace: get_traces(tp),
155
+ is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
164
156
  tp: tp
165
157
  })
166
-
167
- yield(payload) if block_given?
168
-
169
- payload
170
- end
171
-
172
- def tap_init?(klass, tp)
173
- receiver = tp.self
174
- method_name = tp.callee_id
175
-
176
- if klass.ancestors.include?(ActiveRecord::Base)
177
- method_name == :new && receiver.ancestors.include?(klass)
178
- else
179
- method_name == :initialize && receiver.is_a?(klass)
180
- end
181
158
  end
182
159
 
183
- def tap_on?(object, tp)
184
- is_from_target?(object, tp)
160
+ def get_method_object_from(target, method_name)
161
+ Object.instance_method(:method).bind(target).call(method_name)
162
+ rescue NameError
163
+ # if any part of the program uses Refinement to extend its methods
164
+ # we might still get NoMethodError when trying to get that method outside the scope
165
+ nil
185
166
  end
186
167
 
187
- def tap_associations?(object, tp)
188
- return false unless tap_on?(object, tp)
189
-
190
- model_class = object.class
191
- associations = model_class.reflections
192
- associations.keys.include?(tp.callee_id.to_s)
168
+ def get_call_location(tp, padding: 0)
169
+ caller(get_trace_index(tp) + padding).first.split(":")[0..1]
193
170
  end
194
171
 
195
- def tap_passed?(object, tp)
196
- # we don't care about calls from the device instance or helper methods
197
- return false if is_from_target?(self, tp)
198
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
199
-
200
- collect_arguments(tp).values.any? do |value|
201
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
202
- # but not every value supports every conversion methods
203
- object == value rescue false
172
+ def get_trace_index(tp)
173
+ if tp.event == :c_call
174
+ C_CALLER_START_POINT
175
+ else
176
+ CALLER_START_POINT
204
177
  end
205
- rescue
206
- false
207
178
  end
208
179
 
209
- def get_method_object_from(target, method_name)
210
- target.method(method_name)
211
- rescue ArgumentError
212
- method_method = Object.method(:method).unbind
213
- method_method.bind(target).call(method_name)
214
- rescue NameError
215
- # if any part of the program uses Refinement to extend its methods
216
- # we might still get NoMethodError when trying to get that method outside the scope
217
- nil
180
+ def get_traces(tp)
181
+ if with_trace_to = options[:with_trace_to]
182
+ trace_index = get_trace_index(tp)
183
+ caller[trace_index..(trace_index + with_trace_to)]
184
+ else
185
+ []
186
+ end
218
187
  end
219
188
 
220
189
  def collect_arguments(tp)
@@ -231,29 +200,28 @@ class TappingDevice
231
200
  end
232
201
 
233
202
  def process_options(options)
234
- options[:filter_by_paths] ||= []
235
- options[:exclude_by_paths] ||= []
236
- options[:with_trace_to] ||= 50
237
- options[:root_device] ||= self
238
- options[:event_type] ||= :return
203
+ options[:filter_by_paths] ||= config[:filter_by_paths]
204
+ options[:exclude_by_paths] ||= config[:exclude_by_paths]
205
+ options[:with_trace_to] ||= config[:with_trace_to]
206
+ options[:event_type] ||= config[:event_type]
207
+ options[:hijack_attr_methods] ||= config[:hijack_attr_methods]
208
+ options[:track_as_records] ||= config[:track_as_records]
209
+ options[:ignore_private] ||= config[:ignore_private]
210
+ options[:only_private] ||= config[:only_private]
211
+ # for debugging the gem more easily
212
+ options[:force_recording] ||= false
213
+
239
214
  options[:descendants] ||= []
240
- options[:track_as_records] ||= false
215
+ options[:root_device] ||= self
241
216
  options
242
217
  end
243
218
 
244
- def stop_if_condition_fulfilled(payload)
245
- if @stop_when&.call(payload)
246
- stop!
247
- root_device.stop!
248
- end
249
- end
250
-
251
- def is_from_target?(object, tp)
219
+ def is_from_target?(tp)
252
220
  comparsion = tp.self
253
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
221
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
254
222
  end
255
223
 
256
- def is_the_same_record?(target, comparsion)
224
+ def is_the_same_record?(comparsion)
257
225
  return false unless options[:track_as_records]
258
226
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
259
227
  primary_key = target.class.primary_key
@@ -264,7 +232,7 @@ class TappingDevice
264
232
  def record_call!(payload)
265
233
  return if @disabled
266
234
 
267
- @output_block.call(OutputPayload.init(payload)) if @output_block
235
+ write_output!(payload) if @output_writer
268
236
 
269
237
  if @block
270
238
  root_device.calls << @block.call(payload)
@@ -273,7 +241,18 @@ class TappingDevice
273
241
  end
274
242
  end
275
243
 
276
- def with_condition_satisfied?(payload)
277
- @with_condition.blank? || @with_condition.call(payload)
244
+ def write_output!(payload)
245
+ @output_writer.write!(payload)
246
+ end
247
+
248
+ def stop_if_condition_fulfilled!(payload)
249
+ if @stop_when&.call(payload)
250
+ stop!
251
+ root_device.stop!
252
+ end
253
+ end
254
+
255
+ def config
256
+ TappingDevice.config
278
257
  end
279
258
  end
@@ -0,0 +1,27 @@
1
+ require "active_support/configurable"
2
+ require "active_support/concern"
3
+
4
+ class TappingDevice
5
+ module Configurable
6
+ extend ActiveSupport::Concern
7
+
8
+ DEFAULTS = {
9
+ filter_by_paths: [],
10
+ exclude_by_paths: [],
11
+ with_trace_to: 50,
12
+ event_type: :return,
13
+ hijack_attr_methods: false,
14
+ track_as_records: false,
15
+ ignore_private: false,
16
+ only_private: false
17
+ }.merge(TappingDevice::Output::DEFAULT_OPTIONS)
18
+
19
+ included do
20
+ include ActiveSupport::Configurable
21
+
22
+ DEFAULTS.each do |key, value|
23
+ config[key] = value
24
+ end
25
+ end
26
+ end
27
+ 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,51 @@
1
+ class TappingDevice
2
+ class MethodHijacker
3
+ attr_reader :target
4
+
5
+ def initialize(target)
6
+ @target = target
7
+ end
8
+
9
+ def hijack_methods!
10
+ target.methods.each do |method_name|
11
+ if is_writer_method?(method_name)
12
+ redefine_writer_method!(method_name)
13
+ elsif is_reader_method?(method_name)
14
+ redefine_reader_method!(method_name)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def is_writer_method?(method_name)
22
+ has_definition_source?(method_name) && method_name.match?(/\w+=/) && target.method(method_name).source.match?(/attr_writer|attr_accessor/)
23
+ end
24
+
25
+ def is_reader_method?(method_name)
26
+ has_definition_source?(method_name) && target.method(method_name).source.match?(/attr_reader|attr_accessor/)
27
+ end
28
+
29
+ def has_definition_source?(method_name)
30
+ target.method(method_name).source_location
31
+ end
32
+
33
+ def redefine_writer_method!(method_name)
34
+ ivar_name = "@#{method_name.to_s.sub("=", "")}"
35
+
36
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
37
+ def #{method_name}(val)
38
+ #{ivar_name} = val
39
+ end
40
+ RUBY
41
+ end
42
+
43
+ def redefine_reader_method!(method_name)
44
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
45
+ def #{method_name}
46
+ @#{method_name}
47
+ end
48
+ RUBY
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ require "tapping_device/output/payload"
2
+ require "tapping_device/output/writer"
3
+ require "tapping_device/output/stdout_writer"
4
+ require "tapping_device/output/file_writer"
5
+
6
+ class TappingDevice
7
+ module Output
8
+ DEFAULT_OPTIONS = {
9
+ inspect: false,
10
+ colorize: true,
11
+ log_file: "/tmp/tapping_device.log"
12
+ }
13
+
14
+ module Helpers
15
+ def and_write(payload_method = nil, options: {}, &block)
16
+ and_output(payload_method, options: options, writer_klass: FileWriter, &block)
17
+ end
18
+
19
+ def and_print(payload_method = nil, options: {}, &block)
20
+ and_output(payload_method, options: options, writer_klass: StdoutWriter, &block)
21
+ end
22
+
23
+ def and_output(payload_method = nil, options: {}, writer_klass:, &block)
24
+ output_block = generate_output_block(payload_method, block)
25
+ @output_writer = writer_klass.new(options, output_block)
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def generate_output_block(payload_method, block)
32
+ if block
33
+ block
34
+ elsif payload_method
35
+ -> (output_payload, output_options) { output_payload.send(payload_method, output_options) }
36
+ else
37
+ raise "need to provide either a payload method name or a block"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end