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.
@@ -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,112 @@
1
+ class TappingDevice
2
+ module Trackers
3
+ class MutationTracker < TappingDevice
4
+ def initialize(options, &block)
5
+ options[:hijack_attr_methods] = true
6
+ super
7
+ @snapshot_stack = []
8
+ end
9
+
10
+ def track(object)
11
+ super
12
+ insert_snapshot_taking_trace_point
13
+ self
14
+ end
15
+
16
+ def stop!
17
+ super
18
+ @ivar_snapshot_trace_point.disable
19
+ end
20
+
21
+ private
22
+
23
+ # we need to snapshot instance variables at the beginning of every method call
24
+ # so we can get a correct state for the later comparison
25
+ def insert_snapshot_taking_trace_point
26
+ @ivar_snapshot_trace_point = build_minimum_trace_point(event_type: :call) do
27
+ snapshot_instance_variables
28
+ end
29
+
30
+ @ivar_snapshot_trace_point.enable unless TappingDevice.suspend_new
31
+ end
32
+
33
+ def filter_condition_satisfied?(tp)
34
+ return false unless is_from_target?(tp)
35
+
36
+ if snapshot_capturing_event?(tp)
37
+ true
38
+ else
39
+ @latest_instance_variables = target_instance_variables
40
+ @instance_variables_snapshot = @snapshot_stack.pop
41
+
42
+ @latest_instance_variables != @instance_variables_snapshot
43
+ end
44
+ end
45
+
46
+ def build_payload(tp:, filepath:, line_number:)
47
+ payload = super
48
+
49
+ if change_capturing_event?(tp)
50
+ payload[:ivar_changes] = capture_ivar_changes
51
+ end
52
+
53
+ payload
54
+ end
55
+
56
+ def capture_ivar_changes
57
+ changes = {}
58
+
59
+ additional_keys = @latest_instance_variables.keys - @instance_variables_snapshot.keys
60
+ additional_keys.each do |key|
61
+ changes[key] = {before: Output::Payload::UNDEFINED, after: @latest_instance_variables[key]}
62
+ end
63
+
64
+ removed_keys = @instance_variables_snapshot.keys - @latest_instance_variables.keys
65
+ removed_keys.each do |key|
66
+ changes[key] = {before: @instance_variables_snapshot[key], after: Output::Payload::UNDEFINED}
67
+ end
68
+
69
+ remained_keys = @latest_instance_variables.keys - additional_keys
70
+ remained_keys.each do |key|
71
+ next if @latest_instance_variables[key] == @instance_variables_snapshot[key]
72
+ changes[key] = {before: @instance_variables_snapshot[key], after: @latest_instance_variables[key]}
73
+ end
74
+
75
+ changes
76
+ end
77
+
78
+ def snapshot_instance_variables
79
+ @snapshot_stack.push(target_instance_variables)
80
+ end
81
+
82
+ def target_instance_variables
83
+ target.instance_variables.each_with_object({}) do |ivar, hash|
84
+ hash[ivar] = target.instance_variable_get(ivar)
85
+ end
86
+ end
87
+
88
+ def snapshot_capturing_event?(tp)
89
+ tp.event == :call
90
+ end
91
+
92
+ def change_capturing_event?(tp)
93
+ !snapshot_capturing_event?(tp)
94
+ end
95
+
96
+ # belows are debugging helpers
97
+ # I'll leave them for a while in case there's a bug in the tracker
98
+ def print_snapshot_stack(tp)
99
+ puts("===== STACK - #{tp.callee_id} (#{tp.event}) =====")
100
+ puts(@snapshot_stack)
101
+ puts("================ END STACK =================")
102
+ end
103
+
104
+ def print_state_comparison
105
+ puts("###############")
106
+ puts(@latest_instance_variables)
107
+ puts(@instance_variables_snapshot)
108
+ puts("###############")
109
+ end
110
+ end
111
+ end
112
+ 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.0"
2
+ VERSION = "0.5.5"
3
3
  end
@@ -32,10 +32,12 @@ 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
+ spec.add_dependency "activesupport"
37
+
35
38
  spec.add_development_dependency "sqlite3", ">= 1.3.6"
36
39
  spec.add_development_dependency "database_cleaner"
37
40
  spec.add_development_dependency "bundler", "~> 2.0"
38
- spec.add_development_dependency "pry"
39
41
  spec.add_development_dependency "rake", "~> 13.0"
40
42
  spec.add_development_dependency "rspec", "~> 3.0"
41
43
  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.0
4
+ version: 0.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - st0012
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-05-25 00:00:00.000000000 Z
11
+ date: 2020-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -25,27 +25,27 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '5.2'
27
27
  - !ruby/object:Gem::Dependency
28
- name: sqlite3
28
+ name: pry
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 1.3.6
34
- type: :development
33
+ version: '0'
34
+ type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 1.3.6
40
+ version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
- name: database_cleaner
42
+ name: activesupport
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0'
48
- type: :development
48
+ type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
@@ -53,21 +53,21 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
55
  - !ruby/object:Gem::Dependency
56
- name: bundler
56
+ name: sqlite3
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '2.0'
61
+ version: 1.3.6
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '2.0'
68
+ version: 1.3.6
69
69
  - !ruby/object:Gem::Dependency
70
- name: pry
70
+ name: database_cleaner
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: bundler
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: rake
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -137,6 +151,7 @@ files:
137
151
  - ".rspec"
138
152
  - ".ruby-version"
139
153
  - ".travis.yml"
154
+ - CHANGELOG.md
140
155
  - CODE_OF_CONDUCT.md
141
156
  - Gemfile
142
157
  - Gemfile.lock
@@ -147,14 +162,25 @@ files:
147
162
  - bin/setup
148
163
  - images/print_calls - single entry.png
149
164
  - images/print_calls.png
165
+ - images/print_mutations.png
150
166
  - images/print_traces.png
151
167
  - lib/tapping_device.rb
168
+ - lib/tapping_device/configurable.rb
152
169
  - lib/tapping_device/exceptions.rb
153
170
  - lib/tapping_device/manageable.rb
154
- - lib/tapping_device/output_payload.rb
171
+ - lib/tapping_device/method_hijacker.rb
172
+ - lib/tapping_device/output.rb
173
+ - lib/tapping_device/output/file_writer.rb
174
+ - lib/tapping_device/output/payload.rb
175
+ - lib/tapping_device/output/stdout_writer.rb
176
+ - lib/tapping_device/output/writer.rb
155
177
  - lib/tapping_device/payload.rb
156
- - lib/tapping_device/sql_tapping_methods.rb
157
178
  - lib/tapping_device/trackable.rb
179
+ - lib/tapping_device/trackers/association_call_tracker.rb
180
+ - lib/tapping_device/trackers/initialization_tracker.rb
181
+ - lib/tapping_device/trackers/method_call_tracker.rb
182
+ - lib/tapping_device/trackers/mutation_tracker.rb
183
+ - lib/tapping_device/trackers/passed_tracker.rb
158
184
  - lib/tapping_device/version.rb
159
185
  - tapping_device.gemspec
160
186
  homepage: https://github.com/st0012/tapping_device
@@ -1,145 +0,0 @@
1
- class TappingDevice
2
- class OutputPayload < Payload
3
- alias :raw_arguments :arguments
4
- alias :raw_return_value :return_value
5
-
6
- def method_name(options = {})
7
- ":#{super(options)}"
8
- end
9
-
10
- def arguments(options = {})
11
- generate_string_result(raw_arguments, options[:inspect])
12
- end
13
-
14
- def return_value(options = {})
15
- generate_string_result(raw_return_value, options[:inspect])
16
- end
17
-
18
- def self.full_color_code(code)
19
- end
20
-
21
- COLOR_CODES = {
22
- green: 10,
23
- yellow: 11,
24
- blue: 12,
25
- megenta: 13,
26
- cyan: 14,
27
- orange: 214
28
- }
29
-
30
- COLORS = COLOR_CODES.each_with_object({}) do |(name, code), hash|
31
- hash[name] = "\u001b[38;5;#{code}m"
32
- end.merge(
33
- reset: "\u001b[0m",
34
- nocolor: ""
35
- )
36
-
37
- PAYLOAD_ATTRIBUTES = {
38
- method_name: {symbol: "", color: COLORS[:blue]},
39
- location: {symbol: "from:", color: COLORS[:green]},
40
- sql: {symbol: "QUERIES", color: COLORS[:nocolor]},
41
- return_value: {symbol: "=>", color: COLORS[:megenta]},
42
- arguments: {symbol: "<=", color: COLORS[:orange]},
43
- defined_class: {symbol: "#", color: COLORS[:yellow]}
44
- }
45
-
46
- PAYLOAD_ATTRIBUTES.each do |attribute, attribute_options|
47
- color = attribute_options[:color]
48
-
49
- alias_method "original_#{attribute}".to_sym, attribute
50
-
51
- # regenerate attributes with `colorize: true` support
52
- define_method attribute do |options = {}|
53
- call_result = send("original_#{attribute}", options)
54
-
55
- if options[:colorize]
56
- "#{color}#{call_result}#{COLORS[:reset]}"
57
- else
58
- call_result
59
- end
60
- end
61
-
62
- define_method "#{attribute}_with_color" do |options = {}|
63
- send(attribute, options.merge(colorize: true))
64
- end
65
-
66
- PAYLOAD_ATTRIBUTES.each do |and_attribute, and_attribute_options|
67
- next if and_attribute == attribute
68
-
69
- define_method "#{attribute}_and_#{and_attribute}" do |options = {}|
70
- "#{send(attribute, options)} #{and_attribute_options[:symbol]} #{send(and_attribute, options)}"
71
- end
72
-
73
- define_method "#{attribute}_and_#{and_attribute}_with_color" do |options = {}|
74
- send("#{attribute}_and_#{and_attribute}", options.merge(colorize: true))
75
- end
76
- end
77
- end
78
-
79
- def passed_at(options = {})
80
- with_method_head = options.fetch(:with_method_head, false)
81
- arg_name = raw_arguments.keys.detect { |k| raw_arguments[k] == target }
82
-
83
- return unless arg_name
84
-
85
- arg_name = ":#{arg_name}"
86
- arg_name = value_with_color(arg_name, :orange) if options[:colorize]
87
- msg = "Passed as #{arg_name} in '#{defined_class(options)}##{method_name(options)}' at #{location(options)}"
88
- msg += "\n > #{method_head.strip}" if with_method_head
89
- msg
90
- end
91
-
92
- def detail_call_info(options = {})
93
- <<~MSG
94
- #{method_name_and_defined_class(options)}
95
- from: #{location(options)}
96
- <= #{arguments(options)}
97
- => #{return_value(options)}
98
-
99
- MSG
100
- end
101
-
102
- private
103
-
104
- def value_with_color(value, color)
105
- "#{COLORS[color]}#{value}#{COLORS[:reset]}"
106
- end
107
-
108
- def generate_string_result(obj, inspect)
109
- case obj
110
- when Array
111
- array_to_string(obj, inspect)
112
- when Hash
113
- hash_to_string(obj, inspect)
114
- when String
115
- "\"#{obj}\""
116
- else
117
- inspect ? obj.inspect : obj.to_s
118
- end
119
- end
120
-
121
- def array_to_string(array, inspect)
122
- elements_string = array.map do |elem|
123
- generate_string_result(elem, inspect)
124
- end.join(", ")
125
- "[#{elements_string}]"
126
- end
127
-
128
- def hash_to_string(hash, inspect)
129
- elements_string = hash.map do |key, value|
130
- "#{key.to_s}: #{generate_string_result(value, inspect)}"
131
- end.join(", ")
132
- "{#{elements_string}}"
133
- end
134
-
135
- def obj_to_string(element, inspect)
136
- to_string_method = inspect ? :inspect : :to_s
137
-
138
- if !inspect && element.is_a?(String)
139
- "\"#{element}\""
140
- else
141
- element.send(to_string_method)
142
- end
143
- end
144
- end
145
- end
@@ -1,89 +0,0 @@
1
- class TappingDevice
2
- module SqlTappingMethods
3
- CALL_STACK_SKIPPABLE_METHODS = [:transaction, :tap]
4
-
5
- # SQLListener acts like an interface for us to intercept activerecord query instrumentations
6
- # this means we only need to register one subscriber no matter how many objects we want to tap on
7
- class SQLListener
8
- def call(name, start, finish, message_id, values);end
9
- end
10
- @@sql_listener = SQLListener.new
11
-
12
- ActiveSupport::Notifications.subscribe("sql.active_record", @@sql_listener)
13
-
14
- def tap_sql!(object)
15
- @call_stack = []
16
- @target ||= object
17
- @trace_point = with_trace_point_on_target(object, event: [:call, :c_call]) do |start_tp|
18
- ########## Check if the call is worth recording ##########
19
- filepath, line_number = get_call_location(start_tp, padding: 1) # we need extra padding because of `with_trace_point_on_target`
20
- method = start_tp.callee_id
21
- next if already_recording?(method)
22
-
23
- ########## Start the recording ##########
24
- # 1. Mark recording state by pushing method into @call_stack
25
- # 2. Subscribe sql instrumentations generated by activerecord
26
- # 3. Record those sqls and run device callbacks
27
- # 4. Start waiting for current call's return callback
28
- @call_stack.push(method)
29
- payload = build_payload(tp: start_tp, filepath: filepath, line_number: line_number)
30
- device = tap_on_sql_instrumentation!(payload)
31
-
32
- with_trace_point_on_target(object, event: :return) do |return_tp|
33
- next unless return_tp.callee_id == method
34
-
35
- ########## End recording ##########
36
- # 1. Close itself
37
- # 2. Stop our subscription on SQLListener
38
- # 3. Remove current method from @call_stack
39
- # 4. Stop the device if stop condition is fulfilled
40
- return_tp.disable
41
- device.stop!
42
- @call_stack.pop
43
- stop_if_condition_fulfilled(payload)
44
-
45
- ########## Track descendant objects ##########
46
- # if the method creates another Relation object
47
- if return_tp.defined_class == ActiveRecord::QueryMethods
48
- create_child_device.tap_sql!(return_tp.return_value)
49
- end
50
- end.enable
51
- end
52
-
53
- @trace_point.enable unless self.class.suspend_new
54
-
55
- self
56
- end
57
- end
58
-
59
- private
60
-
61
- def tap_on_sql_instrumentation!(payload)
62
- device = TappingDevice.new do |sql_listener_payload|
63
- values = sql_listener_payload.arguments[:values]
64
-
65
- next if should_be_skipped_by_paths?(payload.filepath) ||
66
- ["SCHEMA", "TRANSACTION"].include?(values[:name]) ||
67
- values[:sql].match?(/SAVEPOINT/)
68
-
69
- payload[:sql] = values[:sql]
70
- record_call!(payload)
71
- end
72
- device.tap_on!(@@sql_listener)
73
- end
74
-
75
- # usually, AR's query methods (like `first`) will end up calling `find_by_sql`
76
- # then to TappingDevice, both `first` and `find_by_sql` generates the sql
77
- # but the results are duplicated, we should only consider the `first` call
78
- def already_recording?(method)
79
- !@call_stack.empty? || CALL_STACK_SKIPPABLE_METHODS.include?(method)
80
- end
81
-
82
- def with_trace_point_on_target(object, event:)
83
- TracePoint.new(*event) do |tp|
84
- if is_from_target?(object, tp)
85
- yield(tp)
86
- end
87
- end
88
- end
89
- end