stinkingtoe 1.11.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d88e8ea94e8b670203c820b5aa082e82f0515e5e6b995911977431128eea6909
4
+ data.tar.gz: 80f43ac62f3632ab67527be25ad2c3e9c896f59059c61d798f9927ff5de7d7f7
5
+ SHA512:
6
+ metadata.gz: 7c876857d5c40dfc152150993ea80e58f58ec531b65a00d944914a81e9d865dc9d2e7a725588b853269bdc7cb796fa3a3b77ae69e98d76bc7d21c204f8116888
7
+ data.tar.gz: 07ad331dbbb5cc229f9e68ce4c1dc27f70dec49fb0474d02a5477be4c755d0f9a539c67acb82ffd6f85d1cf3bef78352a8f39bd74f7b695589e27624872d2fa7
data/.DS_Store ADDED
Binary file
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ tmp
4
+ stinkingtoe_test
5
+ Gemfile.lock
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.6.8
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ rails_version = ENV["RAILS_VERSION"] || "6.1.0"
6
+ if rails_version == "main"
7
+ gem "rails", github: "rails/rails"
8
+ else
9
+ gem "rails", "~> #{rails_version}"
10
+ end
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ # Copyright (c) 2026
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # stinkingtoe
2
+
3
+ Add comments to your ActiveRecord queries. By default, this appends the application, controller, and action names as a comment to each query.
4
+
5
+ This is useful for tracking queries in log files and identifying the source of slow queries.
6
+
7
+ For example, once enabled, your logs will look like:
8
+
9
+ Account Load (0.3ms) SELECT `accounts`.* FROM `accounts`
10
+ WHERE `accounts`.`queenbee_id` = 1234567890
11
+ LIMIT 1
12
+ /*application:APP,controller:project_imports,action:show*/
13
+
14
+ ## Installation
15
+
16
+ # Gemfile
17
+ gem 'stinkingtoe'
18
+
19
+ ### Customization
20
+
21
+ Optionally, you can set the application name shown in the log like so in an initializer (e.g. `config/initializers/stinkingtoe.rb`):
22
+
23
+ StinkingToe.application_name = "APP"
24
+
25
+ The name will default to your Rails application name.
26
+
27
+ #### Components
28
+
29
+ You can also configure the components of the comment that will be appended,
30
+ by setting `StinkingToe::Comment.components`. By default, this is set to:
31
+
32
+ StinkingToe::Comment.components = [:application, :controller, :action]
33
+
34
+ Which results in a comment of
35
+ `application:#{application_name},controller:#{controller.name},action:#{action_name}`.
36
+
37
+ You can re-order or remove these components. You can also add additional
38
+ comment components of your desire by defining new module methods for
39
+ `StinkingToe::Comment` which return a string. For example:
40
+
41
+ module StinkingToe
42
+ module Comment
43
+ def self.mycommentcomponent
44
+ "TEST"
45
+ end
46
+ end
47
+ end
48
+
49
+ StinkingToe::Comment.components = [:application, :mycommentcomponent]
50
+
51
+ Which will result in a comment like
52
+ `application:#{application_name},mycommentcomponent:TEST`
53
+ The calling controller is available to these methods via `@controller`.
54
+
55
+ StinkingToe ships with `:application`, `:controller`, and `:action` enabled by
56
+ default. In addition, implementation is provided for:
57
+ * `:line` (for file and line number calling query). :line supports
58
+ a configuration by setting a regexp in `StinkingToe::Comment.lines_to_ignore`
59
+ to exclude parts of the stacktrace from inclusion in the line comment.
60
+ * `:controller_with_namespace` to include the full classname (including namespace)
61
+ of the controller.
62
+ * `:job` to include the classname of the ActiveJob being performed.
63
+ * `:hostname` to include ```Socket.gethostname```.
64
+ * `:pid` to include current process id.
65
+ * `:db_host` to include the configured database hostname.
66
+ * `:socket` to include the configured database socket.
67
+ * `:database` to include the configured database name.
68
+
69
+ Pull requests for other included comment components are welcome.
70
+
71
+ #### Prepend comments
72
+
73
+ By default, `stinkingtoe` appends comments at the end of queries. Some databases, like MySQL, truncate query text—for example, in slow query logs and certain InnoDB internal table queries where the query exceeds 1024 bytes.
74
+
75
+ In order to not lose the `stinkingtoe` comments from your logs, you can prepend the comments using this option:
76
+
77
+ StinkingToe::Comment.prepend_comment = true
78
+
79
+ #### Inline query annotations
80
+
81
+ In addition to the request or job-level component-based annotations,
82
+ `stinkingtoe` may be used to add inline annotations to specific queries using a
83
+ block-based API.
84
+
85
+ For example, the following code:
86
+
87
+ StinkingToe.with_annotation("foo") do
88
+ Account.where(queenbee_id: 1234567890).first
89
+ end
90
+
91
+ will issue this query:
92
+
93
+ Account Load (0.3ms) SELECT `accounts`.* FROM `accounts`
94
+ WHERE `accounts`.`queenbee_id` = 1234567890
95
+ LIMIT 1
96
+ /*application:APP,controller:project_imports,action:show*/ /*foo*/
97
+
98
+ Nesting `with_annotation` blocks will concatenate the comment strings.
99
+
100
+ ### Caveats
101
+
102
+ #### Prepared statements
103
+
104
+ Be careful when using `stinkingtoe` with prepared statements. If you use a component
105
+ like `request_id` then every query will be unique and so ActiveRecord will create
106
+ a new prepared statement for each potentially exhausting system resources.
107
+ [Disable prepared statements](https://guides.rubyonrails.org/configuring.html#configuring-a-postgresql-database)
108
+ if you wish to use components with high cardinality values.
109
+
110
+ ## Contributing
111
+
112
+ Start by bundling and creating the test database:
113
+
114
+ bundle
115
+ rake db:mysql:create
116
+ rake db:postgresql:create
117
+
118
+ Then, running `rake` will run the tests on all the database adapters (`mysql`, `mysql2`, `postgresql` and `sqlite`):
119
+
120
+ rake
data/Rakefile ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ task :default => ['test:all']
5
+
6
+ namespace :test do
7
+ desc "test all drivers"
8
+ task :all => [:mysql2, :postgresql, :sqlite]
9
+
10
+ desc "test mysql2 driver"
11
+ task :mysql2 do
12
+ sh "DRIVER=mysql2 bundle exec ruby -Ilib -Itest test/*_test.rb"
13
+ end
14
+
15
+ desc "test PostgreSQL driver"
16
+ task :postgresql do
17
+ sh "DRIVER=postgresql DB_USERNAME=postgres bundle exec ruby -Ilib -Itest test/*_test.rb"
18
+ end
19
+
20
+ desc "test sqlite3 driver"
21
+ task :sqlite do
22
+ sh "DRIVER=sqlite3 bundle exec ruby -Ilib -Itest test/*_test.rb"
23
+ end
24
+ end
25
+
26
+ namespace :db do
27
+
28
+ desc "reset all databases"
29
+ task :reset => [:"mysql:reset", :"postgresql:reset"]
30
+
31
+ namespace :mysql do
32
+ desc "reset MySQL database"
33
+ task :reset => [:drop, :create]
34
+
35
+ desc "create MySQL database"
36
+ task :create do
37
+ sh 'mysql -u root -e "create database stinkingtoe_test;"'
38
+ end
39
+
40
+ desc "drop MySQL database"
41
+ task :drop do
42
+ sh 'mysql -u root -e "drop database if exists stinkingtoe_test;"'
43
+ end
44
+ end
45
+
46
+ namespace :postgresql do
47
+ desc "reset PostgreSQL database"
48
+ task :reset => [:drop, :create]
49
+
50
+ desc "create PostgreSQL database"
51
+ task :create do
52
+ sh 'createdb -U postgres stinkingtoe_test'
53
+ end
54
+
55
+ desc "drop PostgreSQL database"
56
+ task :drop do
57
+ sh 'psql -d postgres -U postgres -c "DROP DATABASE IF EXISTS stinkingtoe_test"'
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module StinkingToe
6
+ module Comment
7
+ mattr_accessor :components, :lines_to_ignore, :prepend_comment
8
+ StinkingToe::Comment.components ||= [:application, :controller, :action]
9
+
10
+ def self.update!(controller = nil)
11
+ self.stinkingtoe_controller = controller
12
+ end
13
+
14
+ def self.update_job!(job)
15
+ self.stinkingtoe_job = job
16
+ end
17
+
18
+ def self.update_adapter!(adapter)
19
+ self.stinkingtoe_adapter = adapter
20
+ end
21
+
22
+ def self.construct_comment
23
+ ret = String.new
24
+ self.components.each do |c|
25
+ component_value = self.send(c)
26
+ if component_value.present?
27
+ ret << "#{c}:#{component_value},"
28
+ end
29
+ end
30
+ ret.chop!
31
+ ret = self.escape_sql_comment(ret)
32
+ ret
33
+ end
34
+
35
+ def self.construct_inline_comment
36
+ return nil if inline_annotations.none?
37
+ escape_sql_comment(inline_annotations.join)
38
+ end
39
+
40
+ def self.escape_sql_comment(str)
41
+ while str.include?('/*') || str.include?('*/')
42
+ str = str.gsub('/*', '').gsub('*/', '')
43
+ end
44
+ str
45
+ end
46
+
47
+ def self.clear!
48
+ self.stinkingtoe_controller = nil
49
+ end
50
+
51
+ def self.clear_job!
52
+ self.stinkingtoe_job = nil
53
+ end
54
+
55
+ private
56
+ def self.stinkingtoe_controller=(controller)
57
+ Thread.current[:stinkingtoe_controller] = controller
58
+ end
59
+
60
+ def self.stinkingtoe_controller
61
+ Thread.current[:stinkingtoe_controller]
62
+ end
63
+
64
+ def self.stinkingtoe_job=(job)
65
+ Thread.current[:stinkingtoe_job] = job
66
+ end
67
+
68
+ def self.stinkingtoe_job
69
+ Thread.current[:stinkingtoe_job]
70
+ end
71
+
72
+ def self.stinkingtoe_adapter=(adapter)
73
+ Thread.current[:stinkingtoe_adapter] = adapter
74
+ end
75
+
76
+ def self.stinkingtoe_adapter
77
+ Thread.current[:stinkingtoe_adapter]
78
+ end
79
+
80
+ def self.application
81
+ if defined?(Rails.application)
82
+ StinkingToe.application_name ||= Rails.application.class.name.split("::").first
83
+ else
84
+ StinkingToe.application_name ||= "rails"
85
+ end
86
+
87
+ StinkingToe.application_name
88
+ end
89
+
90
+ def self.job
91
+ stinkingtoe_job.class.name if stinkingtoe_job
92
+ end
93
+
94
+ def self.controller
95
+ stinkingtoe_controller.controller_name if stinkingtoe_controller.respond_to? :controller_name
96
+ end
97
+
98
+ def self.controller_with_namespace
99
+ stinkingtoe_controller.class.name if stinkingtoe_controller
100
+ end
101
+
102
+ def self.action
103
+ stinkingtoe_controller.action_name if stinkingtoe_controller.respond_to? :action_name
104
+ end
105
+
106
+ def self.sidekiq_job
107
+ stinkingtoe_job["class"] if stinkingtoe_job && stinkingtoe_job.respond_to?(:[])
108
+ end
109
+
110
+ DEFAULT_LINES_TO_IGNORE_REGEX = %r{\.rvm|/ruby/gems/|vendor/|stinkingtoe|rbenv|monitor\.rb.*mon_synchronize}
111
+
112
+ def self.line
113
+ StinkingToe::Comment.lines_to_ignore ||= DEFAULT_LINES_TO_IGNORE_REGEX
114
+
115
+ last_line = caller_locations.detect do |loc|
116
+ !loc.path.match?(StinkingToe::Comment.lines_to_ignore)
117
+ end
118
+ if last_line
119
+ last_line = last_line.to_s
120
+
121
+ root = if defined?(Rails) && Rails.respond_to?(:root)
122
+ Rails.root.to_s
123
+ elsif defined?(RAILS_ROOT)
124
+ RAILS_ROOT
125
+ else
126
+ ""
127
+ end
128
+ if last_line.start_with? root
129
+ last_line = last_line[root.length..-1]
130
+ end
131
+ last_line
132
+ end
133
+ end
134
+
135
+ def self.hostname
136
+ @cached_hostname ||= Socket.gethostname
137
+ end
138
+
139
+ def self.pid
140
+ Process.pid
141
+ end
142
+
143
+ def self.request_id
144
+ if stinkingtoe_controller.respond_to?(:request) && stinkingtoe_controller.request.respond_to?(:uuid)
145
+ stinkingtoe_controller.request.uuid
146
+ end
147
+ end
148
+
149
+ def self.socket
150
+ if self.connection_config.present?
151
+ self.connection_config[:socket]
152
+ end
153
+ end
154
+
155
+ def self.db_host
156
+ if self.connection_config.present?
157
+ self.connection_config[:host]
158
+ end
159
+ end
160
+
161
+ def self.database
162
+ if self.connection_config.present?
163
+ self.connection_config[:database]
164
+ end
165
+ end
166
+
167
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new('6.1')
168
+ def self.connection_config
169
+ return if stinkingtoe_adapter.pool.nil?
170
+ stinkingtoe_adapter.pool.spec.config
171
+ end
172
+ else
173
+ def self.connection_config
174
+ # `pool` might be a NullPool which has no db_config
175
+ return unless stinkingtoe_adapter.pool.respond_to?(:db_config)
176
+ stinkingtoe_adapter.pool.db_config.configuration_hash
177
+ end
178
+ end
179
+
180
+ def self.inline_annotations
181
+ Thread.current[:stinkingtoe_inline_annotations] ||= []
182
+ end
183
+ end
184
+
185
+ end
@@ -0,0 +1,74 @@
1
+ require 'stinkingtoe'
2
+ require 'rails/railtie'
3
+
4
+ module StinkingToe
5
+ class Railtie < Rails::Railtie
6
+ initializer 'marginalia.insert' do
7
+ ActiveSupport.on_load :active_record do
8
+ StinkingToe::Railtie.insert_into_active_record
9
+ end
10
+
11
+ ActiveSupport.on_load :action_controller do
12
+ StinkingToe::Railtie.insert_into_action_controller
13
+ end
14
+
15
+ ActiveSupport.on_load :active_job do
16
+ StinkingToe::Railtie.insert_into_active_job
17
+ end
18
+ end
19
+
20
+ def self.insert
21
+ insert_into_active_record
22
+ insert_into_action_controller
23
+ insert_into_active_job
24
+ end
25
+
26
+ def self.insert_into_active_job
27
+ if defined? ActiveJob::Base
28
+ ActiveJob::Base.class_eval do
29
+ around_perform do |job, block|
30
+ begin
31
+ StinkingToe::Comment.update_job! job
32
+ block.call
33
+ ensure
34
+ StinkingToe::Comment.clear_job!
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def self.insert_into_action_controller
42
+ ActionController::Base.send(:include, ActionControllerInstrumentation)
43
+ if defined? ActionController::API
44
+ ActionController::API.send(:include, ActionControllerInstrumentation)
45
+ end
46
+ end
47
+
48
+ def self.insert_into_active_record
49
+ if defined? ActiveRecord::ConnectionAdapters::Mysql2Adapter
50
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter.module_eval do
51
+ include StinkingToe::ActiveRecordInstrumentation
52
+ end
53
+ end
54
+
55
+ if defined? ActiveRecord::ConnectionAdapters::MysqlAdapter
56
+ ActiveRecord::ConnectionAdapters::MysqlAdapter.module_eval do
57
+ include StinkingToe::ActiveRecordInstrumentation
58
+ end
59
+ end
60
+
61
+ if defined? ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
62
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.module_eval do
63
+ include StinkingToe::ActiveRecordInstrumentation
64
+ end
65
+ end
66
+
67
+ if defined? ActiveRecord::ConnectionAdapters::SQLite3Adapter
68
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.module_eval do
69
+ include StinkingToe::ActiveRecordInstrumentation
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,24 @@
1
+ module StinkingToe
2
+
3
+ # Alternative to ActiveJob Instrumentation for Sidekiq.
4
+ module SidekiqInstrumentation
5
+
6
+ class Middleware
7
+ def call(worker, msg, queue)
8
+ StinkingToe::Comment.update_job! msg
9
+ yield
10
+ ensure
11
+ StinkingToe::Comment.clear_job!
12
+ end
13
+ end
14
+
15
+ def self.enable!
16
+ Sidekiq.configure_server do |config|
17
+ config.server_middleware do |chain|
18
+ chain.add StinkingToe::SidekiqInstrumentation::Middleware
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ end
@@ -0,0 +1,121 @@
1
+ require 'stinkingtoe/railtie'
2
+ require 'stinkingtoe/comment'
3
+ require 'stinkingtoe/sidekiq_instrumentation'
4
+
5
+ module StinkingToe
6
+ mattr_accessor :application_name
7
+
8
+ module ActiveRecordInstrumentation
9
+ def self.included(instrumented_class)
10
+ instrumented_class.class_eval do
11
+ if instrumented_class.method_defined?(:execute)
12
+ alias_method :execute_without_stinkingtoe, :execute
13
+ alias_method :execute, :execute_with_stinkingtoe
14
+ end
15
+
16
+ if instrumented_class.private_method_defined?(:execute_and_clear)
17
+ alias_method :execute_and_clear_without_stinkingtoe, :execute_and_clear
18
+ alias_method :execute_and_clear, :execute_and_clear_with_stinkingtoe
19
+ else
20
+ is_mysql2 = defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) &&
21
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter == instrumented_class
22
+ # Dont instrument exec_query on mysql2 as it calls execute internally
23
+ unless is_mysql2
24
+ if instrumented_class.method_defined?(:exec_query)
25
+ alias_method :exec_query_without_stinkingtoe, :exec_query
26
+ alias_method :exec_query, :exec_query_with_stinkingtoe
27
+ end
28
+ end
29
+
30
+ is_postgres = defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) &&
31
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter == instrumented_class
32
+ # Instrument exec_delete and exec_update since they don't call
33
+ # execute internally
34
+ if is_postgres
35
+ if instrumented_class.method_defined?(:exec_delete)
36
+ alias_method :exec_delete_without_stinkingtoe, :exec_delete
37
+ alias_method :exec_delete, :exec_delete_with_stinkingtoe
38
+ end
39
+ if instrumented_class.method_defined?(:exec_update)
40
+ alias_method :exec_update_without_stinkingtoe, :exec_update
41
+ alias_method :exec_update, :exec_update_with_stinkingtoe
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def annotate_sql(sql)
49
+ StinkingToe::Comment.update_adapter!(self)
50
+ comment = StinkingToe::Comment.construct_comment
51
+ if comment.present? && !sql.include?(comment)
52
+ sql = if StinkingToe::Comment.prepend_comment
53
+ "/*#{comment}*/ #{sql}"
54
+ else
55
+ "#{sql} /*#{comment}*/"
56
+ end
57
+ end
58
+ inline_comment = StinkingToe::Comment.construct_inline_comment
59
+ if inline_comment.present? && !sql.include?(inline_comment)
60
+ sql = if StinkingToe::Comment.prepend_comment
61
+ "/*#{inline_comment}*/ #{sql}"
62
+ else
63
+ "#{sql} /*#{inline_comment}*/"
64
+ end
65
+ end
66
+
67
+ sql
68
+ end
69
+
70
+ def execute_with_stinkingtoe(sql, *args)
71
+ execute_without_stinkingtoe(annotate_sql(sql), *args)
72
+ end
73
+ ruby2_keywords :execute_with_stinkingtoe if respond_to?(:ruby2_keywords, true)
74
+
75
+ def exec_query_with_stinkingtoe(sql, *args, **options)
76
+ options[:prepare] ||= false
77
+ exec_query_without_stinkingtoe(annotate_sql(sql), *args, **options)
78
+ end
79
+
80
+ def exec_delete_with_stinkingtoe(sql, *args)
81
+ exec_delete_without_stinkingtoe(annotate_sql(sql), *args)
82
+ end
83
+ ruby2_keywords :exec_delete_with_stinkingtoe if respond_to?(:ruby2_keywords, true)
84
+
85
+ def exec_update_with_stinkingtoe(sql, *args)
86
+ exec_update_without_stinkingtoe(annotate_sql(sql), *args)
87
+ end
88
+ ruby2_keywords :exec_update_with_stinkingtoe if respond_to?(:ruby2_keywords, true)
89
+
90
+ def execute_and_clear_with_stinkingtoe(sql, *args, &block)
91
+ execute_and_clear_without_stinkingtoe(annotate_sql(sql), *args, &block)
92
+ end
93
+ ruby2_keywords :execute_and_clear_with_stinkingtoe if respond_to?(:ruby2_keywords, true)
94
+ end
95
+
96
+ module ActionControllerInstrumentation
97
+ def self.included(instrumented_class)
98
+ instrumented_class.class_eval do
99
+ if respond_to?(:around_action)
100
+ around_action :record_query_comment
101
+ else
102
+ around_filter :record_query_comment
103
+ end
104
+ end
105
+ end
106
+
107
+ def record_query_comment
108
+ StinkingToe::Comment.update!(self)
109
+ yield
110
+ ensure
111
+ StinkingToe::Comment.clear!
112
+ end
113
+ end
114
+
115
+ def self.with_annotation(comment, &block)
116
+ StinkingToe::Comment.inline_annotations.push(comment)
117
+ block.call if block.present?
118
+ ensure
119
+ StinkingToe::Comment.inline_annotations.pop
120
+ end
121
+ end
@@ -0,0 +1,25 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.authors = ["Linda Zhou"]
3
+ gem.email = ["lhmzhou@github.com"]
4
+ gem.homepage = "https://github.com/lhmzhou"
5
+
6
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
7
+ gem.files = `git ls-files`.split("\n")
8
+ gem.test_files = `git ls-files -- {test}/*`.split("\n")
9
+ gem.name = "stinkingtoe"
10
+ gem.require_paths = ["lib"]
11
+ gem.version = "1.11.1"
12
+ gem.license = "MIT"
13
+
14
+ gem.add_runtime_dependency "actionpack", ">= 5.2"
15
+ gem.add_runtime_dependency "activerecord", ">= 5.2"
16
+ gem.add_development_dependency "rake"
17
+ gem.add_development_dependency "mysql2"
18
+ gem.add_development_dependency "pg"
19
+ gem.add_development_dependency "sqlite3"
20
+ gem.add_development_dependency "minitest"
21
+ gem.add_development_dependency "mocha"
22
+ gem.add_development_dependency "sidekiq"
23
+
24
+ gem.summary = gem.description = %q{attach comments to your ActiveRecord queries}
25
+ end
@@ -0,0 +1,372 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'rails/version'
3
+
4
+ def using_rails_api?
5
+ ENV["TEST_RAILS_API"] == true
6
+ end
7
+
8
+ def pool_db_config?
9
+ Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new('6.1')
10
+ end
11
+
12
+ require "minitest/autorun"
13
+ require "mocha/minitest"
14
+ require 'logger'
15
+ require 'pp'
16
+ require 'active_record'
17
+ require 'action_controller'
18
+ require 'active_job'
19
+ require 'sidekiq'
20
+ require 'sidekiq/testing'
21
+
22
+ require 'action_dispatch/middleware/request_id'
23
+
24
+ if using_rails_api?
25
+ require 'rails-api/action_controller/api'
26
+ end
27
+
28
+ # Shim for compatibility with older versions of Minitest
29
+ Minitest::Test = Minitest::Unit::TestCase unless defined?(Minitest::Test)
30
+
31
+ # From version 4.1, ActiveRecord expects `Rails.env` to be
32
+ # defined if `Rails` is defined
33
+ if defined?(Rails) && !defined?(Rails.env)
34
+ module Rails
35
+ def self.env
36
+ end
37
+ end
38
+ end
39
+
40
+ require 'stinkingtoe'
41
+ RAILS_ROOT = File.expand_path(File.dirname(__FILE__))
42
+
43
+ ActiveRecord::Base.establish_connection({
44
+ :adapter => ENV["DRIVER"] || "mysql",
45
+ :host => ENV["DB_HOST"] || "localhost",
46
+ :username => ENV["DB_USERNAME"] || "root",
47
+ :database => "stinkingtoe_test"
48
+ })
49
+
50
+ class Post < ActiveRecord::Base
51
+ end
52
+
53
+ class PostsController < ActionController::Base
54
+ def driver_only
55
+ ActiveRecord::Base.connection.execute "select id from posts"
56
+ if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new('5')
57
+ render body: nil
58
+ else
59
+ render nothing: true
60
+ end
61
+ end
62
+ end
63
+
64
+ module API
65
+ module V1
66
+ class PostsController < ::PostsController
67
+ end
68
+ end
69
+ end
70
+
71
+ class PostsJob < ActiveJob::Base
72
+ def perform
73
+ Post.first
74
+ end
75
+ end
76
+
77
+ class PostsSidekiqJob
78
+ include Sidekiq::Worker
79
+ def perform
80
+ Post.first
81
+ end
82
+ end
83
+
84
+ if using_rails_api?
85
+ class PostsApiController < ActionController::API
86
+ def driver_only
87
+ ActiveRecord::Base.connection.execute "select id from posts"
88
+ head :no_content
89
+ end
90
+ end
91
+ end
92
+
93
+ unless Post.table_exists?
94
+ ActiveRecord::Schema.define do
95
+ create_table "posts", :force => true do |t|
96
+ end
97
+ end
98
+ end
99
+
100
+ StinkingToe::Railtie.insert
101
+
102
+ class StinkingToeTest < Minitest::Test
103
+ def setup
104
+ # Touch the model to avoid spurious schema queries
105
+ Post.first
106
+
107
+ @queries = []
108
+ ActiveSupport::Notifications.subscribe "sql.active_record" do |*args|
109
+ @queries << args.last[:sql]
110
+ end
111
+ @env = Rack::MockRequest.env_for('/')
112
+ ActiveJob::Base.queue_adapter = :inline
113
+ end
114
+
115
+ def test_double_annotate
116
+ ActiveRecord::Base.connection.expects(:annotate_sql).returns("select id from posts").once
117
+ ActiveRecord::Base.connection.send(:select, "select id from posts")
118
+ ensure
119
+ ActiveRecord::Base.connection.unstub(:annotate_sql)
120
+ end
121
+
122
+ def test_exists
123
+ Post.exists?
124
+ assert_match %r{/\*application:rails\*/$}, @queries.last
125
+ end
126
+
127
+ def test_query_commenting_on_mysql_driver_with_no_action
128
+ ActiveRecord::Base.connection.execute "select id from posts"
129
+ assert_match %r{select id from posts /\*application:rails\*/$}, @queries.first
130
+ end
131
+
132
+ if ENV["DRIVER"] =~ /^mysql/
133
+ def test_query_commenting_on_mysql_driver_with_binary_chars
134
+ ActiveRecord::Base.connection.execute "select id from posts /* \x81\x80\u0010\ */"
135
+ assert_equal "select id from posts /* \x81\x80\u0010 */ /*application:rails*/", @queries.first
136
+ end
137
+ end
138
+
139
+ if ENV["DRIVER"] =~ /^postgres/
140
+ def test_query_commenting_on_postgres_update
141
+ ActiveRecord::Base.connection.expects(:annotate_sql).returns("update posts set id = 1").once
142
+ ActiveRecord::Base.connection.send(:exec_update, "update posts set id = 1")
143
+ ensure
144
+ ActiveRecord::Base.connection.unstub(:annotate_sql)
145
+ end
146
+
147
+ def test_query_commenting_on_postgres_delete
148
+ ActiveRecord::Base.connection.expects(:annotate_sql).returns("delete from posts where id = 1").once
149
+ ActiveRecord::Base.connection.send(:exec_delete, "delete from posts where id = 1")
150
+ ensure
151
+ ActiveRecord::Base.connection.unstub(:annotate_sql)
152
+ end
153
+ end
154
+
155
+ def test_query_commenting_on_mysql_driver_with_action
156
+ PostsController.action(:driver_only).call(@env)
157
+ assert_match %r{select id from posts /\*application:rails,controller:posts,action:driver_only\*/$}, @queries.first
158
+
159
+ if using_rails_api?
160
+ PostsApiController.action(:driver_only).call(@env)
161
+ assert_match %r{select id from posts /\*application:rails,controller:posts_api,action:driver_only\*/$}, @queries.second
162
+ end
163
+ end
164
+
165
+ def test_configuring_application
166
+ StinkingToe.application_name = "customapp"
167
+ PostsController.action(:driver_only).call(@env)
168
+ assert_match %r{/\*application:customapp,controller:posts,action:driver_only\*/$}, @queries.first
169
+
170
+ if using_rails_api?
171
+ PostsApiController.action(:driver_only).call(@env)
172
+ assert_match %r{/\*application:customapp,controller:posts_api,action:driver_only\*/$}, @queries.second
173
+ end
174
+ end
175
+
176
+ def test_configuring_query_components
177
+ StinkingToe::Comment.components = [:controller]
178
+ PostsController.action(:driver_only).call(@env)
179
+ assert_match %r{/\*controller:posts\*/$}, @queries.first
180
+
181
+ if using_rails_api?
182
+ PostsApiController.action(:driver_only).call(@env)
183
+ assert_match %r{/\*controller:posts_api\*/$}, @queries.second
184
+ end
185
+ end
186
+
187
+ def test_last_line_component
188
+ StinkingToe::Comment.components = [:line]
189
+ PostsController.action(:driver_only).call(@env)
190
+
191
+ # Because "lines_to_ignore" by default includes "stinkingtoe" and "gem", the
192
+ # extracted line line will be from the line in this file that actually
193
+ # triggers the query.
194
+ assert_match %r{/\*line:test/query_comments_test.rb:[0-9]+:in `driver_only'\*/$}, @queries.first
195
+ end
196
+
197
+ def test_last_line_component_with_lines_to_ignore
198
+ StinkingToe::Comment.lines_to_ignore = /foo bar/
199
+ StinkingToe::Comment.components = [:line]
200
+ PostsController.action(:driver_only).call(@env)
201
+ # Because "lines_to_ignore" does not include "stinkingtoe", the extracted
202
+ # line will be from stinkingtoe/comment.rb.
203
+ assert_match %r{/\*line:.*lib/stinkingtoe/comment.rb:[0-9]+}, @queries.first
204
+ end
205
+
206
+ def test_default_lines_to_ignore_regex
207
+ line = "/gems/a_gem/lib/a_gem.rb:1:in `some_method'"
208
+ call_stack = [line] + caller
209
+
210
+ assert_match(
211
+ call_stack.detect { |line| line !~ StinkingToe::Comment::DEFAULT_LINES_TO_IGNORE_REGEX },
212
+ line
213
+ )
214
+ end
215
+
216
+ def test_hostname_and_pid
217
+ StinkingToe::Comment.components = [:hostname, :pid]
218
+ PostsController.action(:driver_only).call(@env)
219
+ assert_match %r{/\*hostname:#{Socket.gethostname},pid:#{Process.pid}\*/$}, @queries.first
220
+ end
221
+
222
+ def test_controller_with_namespace
223
+ StinkingToe::Comment.components = [:controller_with_namespace]
224
+ API::V1::PostsController.action(:driver_only).call(@env)
225
+ assert_match %r{/\*controller_with_namespace:API::V1::PostsController}, @queries.first
226
+ end
227
+
228
+ def test_db_host
229
+ StinkingToe::Comment.components = [:db_host]
230
+ API::V1::PostsController.action(:driver_only).call(@env)
231
+ assert_match %r{/\*db_host:#{ENV["DB_HOST"] || "localhost"}}, @queries.first
232
+ end
233
+
234
+ def test_database
235
+ StinkingToe::Comment.components = [:database]
236
+ API::V1::PostsController.action(:driver_only).call(@env)
237
+ assert_match %r{/\*database:stinkingtoe_test}, @queries.first
238
+ end
239
+
240
+ if pool_db_config?
241
+ def test_socket
242
+ # setting socket in configuration would break some connections - mock it instead
243
+ pool = ActiveRecord::Base.connection_pool
244
+ pool.db_config.stubs(:configuration_hash).returns({:socket => "stinkingtoe_socket"})
245
+ StinkingToe::Comment.components = [:socket]
246
+ API::V1::PostsController.action(:driver_only).call(@env)
247
+ assert_match %r{/\*socket:stinkingtoe_socket}, @queries.first
248
+ pool.db_config.unstub(:configuration_hash)
249
+ end
250
+ else
251
+ def test_socket
252
+ # setting socket in configuration would break some connections - mock it instead
253
+ pool = ActiveRecord::Base.connection_pool
254
+ pool.spec.stubs(:config).returns({:socket => "stinkingtoe_socket"})
255
+ StinkingToe::Comment.components = [:socket]
256
+ API::V1::PostsController.action(:driver_only).call(@env)
257
+ assert_match %r{/\*socket:stinkingtoe_socket}, @queries.first
258
+ pool.spec.unstub(:config)
259
+ end
260
+ end
261
+
262
+ def test_request_id
263
+ @env["action_dispatch.request_id"] = "some-uuid"
264
+ StinkingToe::Comment.components = [:request_id]
265
+ PostsController.action(:driver_only).call(@env)
266
+ assert_match %r{/\*request_id:some-uuid.*}, @queries.first
267
+
268
+ if using_rails_api?
269
+ PostsApiController.action(:driver_only).call(@env)
270
+ assert_match %r{/\*request_id:some-uuid.*}, @queries.second
271
+ end
272
+ end
273
+
274
+ def test_active_job
275
+ StinkingToe::Comment.components = [:job]
276
+ PostsJob.perform_later
277
+ assert_match %{job:PostsJob}, @queries.first
278
+
279
+ Post.first
280
+ refute_match %{job:PostsJob}, @queries.last
281
+ end
282
+
283
+ def test_active_job_with_sidekiq
284
+ StinkingToe::Comment.components = [:job, :sidekiq_job]
285
+ PostsJob.perform_later
286
+ assert_match %{job:PostsJob}, @queries.first
287
+
288
+ Post.first
289
+ refute_match %{job:PostsJob}, @queries.last
290
+ end
291
+
292
+ def test_sidekiq_job
293
+ StinkingToe::Comment.components = [:sidekiq_job]
294
+ StinkingToe::SidekiqInstrumentation.enable!
295
+
296
+ # Test harness does not run Sidekiq middlewares by default so include testing middleware.
297
+ Sidekiq::Testing.server_middleware do |chain|
298
+ chain.add StinkingToe::SidekiqInstrumentation::Middleware
299
+ end
300
+
301
+ Sidekiq::Testing.fake!
302
+ PostsSidekiqJob.perform_async
303
+ PostsSidekiqJob.drain
304
+ assert_match %{sidekiq_job:PostsSidekiqJob}, @queries.first
305
+
306
+ Post.first
307
+ refute_match %{sidekiq_job:PostsSidekiqJob}, @queries.last
308
+ end
309
+
310
+ def test_good_comment
311
+ assert_equal StinkingToe::Comment.escape_sql_comment('app:foo'), 'app:foo'
312
+ end
313
+
314
+ def test_bad_comments
315
+ assert_equal StinkingToe::Comment.escape_sql_comment('*/; DROP TABLE USERS;/*'), '; DROP TABLE USERS;'
316
+ assert_equal StinkingToe::Comment.escape_sql_comment('**//; DROP TABLE USERS;/*'), '; DROP TABLE USERS;'
317
+ end
318
+
319
+ def test_inline_annotations
320
+ StinkingToe.with_annotation("foo") do
321
+ Post.first
322
+ end
323
+ Post.first
324
+ assert_match %r{/\*foo\*/$}, @queries.first
325
+ refute_match %r{/\*foo\*/$}, @queries.last
326
+ # Assert we're not adding an empty comment, either
327
+ refute_match %r{/\*\s*\*/$}, @queries.last
328
+ end
329
+
330
+ def test_nested_inline_annotations
331
+ StinkingToe.with_annotation("foo") do
332
+ StinkingToe.with_annotation("bar") do
333
+ Post.first
334
+ end
335
+ end
336
+ assert_match %r{/\*foobar\*/$}, @queries.first
337
+ end
338
+
339
+ def test_bad_inline_annotations
340
+ StinkingToe.with_annotation("*/; DROP TABLE USERS;/*") do
341
+ Post.first
342
+ end
343
+ StinkingToe.with_annotation("**//; DROP TABLE USERS;//**") do
344
+ Post.first
345
+ end
346
+ assert_match %r{/\*; DROP TABLE USERS;\*/$}, @queries.first
347
+ assert_match %r{/\*; DROP TABLE USERS;\*/$}, @queries.last
348
+ end
349
+
350
+ def test_inline_annotations_are_deduped
351
+ StinkingToe.with_annotation("foo") do
352
+ ActiveRecord::Base.connection.execute "select id from posts /*foo*/"
353
+ end
354
+ assert_match %r{select id from posts /\*foo\*/ /\*application:rails\*/$}, @queries.first
355
+ end
356
+
357
+ def test_add_comments_to_beginning_of_query
358
+ StinkingToe::Comment.prepend_comment = true
359
+
360
+ ActiveRecord::Base.connection.execute "select id from posts"
361
+ assert_match %r{/\*application:rails\*/ select id from posts$}, @queries.first
362
+ ensure
363
+ StinkingToe::Comment.prepend_comment = nil
364
+ end
365
+
366
+ def teardown
367
+ StinkingToe.application_name = nil
368
+ StinkingToe::Comment.lines_to_ignore = nil
369
+ StinkingToe::Comment.components = [:application, :controller, :action]
370
+ ActiveSupport::Notifications.unsubscribe "sql.active_record"
371
+ end
372
+ end
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stinkingtoe
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.11.1
5
+ platform: ruby
6
+ authors:
7
+ - Linda Zhou
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mysql2
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pg
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
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: minitest
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: mocha
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sidekiq
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: attach comments to your ActiveRecord queries
140
+ email:
141
+ - lhmzhou@github.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".DS_Store"
147
+ - ".gitignore"
148
+ - ".ruby-version"
149
+ - Gemfile
150
+ - LICENSE
151
+ - README.md
152
+ - Rakefile
153
+ - lib/stinkingtoe.rb
154
+ - lib/stinkingtoe/comment.rb
155
+ - lib/stinkingtoe/railtie.rb
156
+ - lib/stinkingtoe/sidekiq_instrumentation.rb
157
+ - stinkingtoe.gemspec
158
+ - test/query_comments_test.rb
159
+ homepage: https://github.com/lhmzhou
160
+ licenses:
161
+ - MIT
162
+ metadata: {}
163
+ post_install_message:
164
+ rdoc_options: []
165
+ require_paths:
166
+ - lib
167
+ required_ruby_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ required_rubygems_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ requirements: []
178
+ rubygems_version: 3.0.3.1
179
+ signing_key:
180
+ specification_version: 4
181
+ summary: attach comments to your ActiveRecord queries
182
+ test_files: []