wt_activerecord_index_spy 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.env.template +3 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +29 -0
- data/.github/workflows/main.yml +51 -0
- data/.gitignore +18 -0
- data/.rspec +2 -0
- data/.rubocop.yml +28 -0
- data/CHANGELOG.md +10 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTORS.md +3 -0
- data/Gemfile +23 -0
- data/LICENSE.md +33 -0
- data/README.md +188 -0
- data/Rakefile +60 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/wt_activerecord_index_spy.rb +71 -0
- data/lib/wt_activerecord_index_spy/aggregator.rb +59 -0
- data/lib/wt_activerecord_index_spy/notification_listener.rb +106 -0
- data/lib/wt_activerecord_index_spy/query_analyser.rb +61 -0
- data/lib/wt_activerecord_index_spy/query_analyser/mysql.rb +44 -0
- data/lib/wt_activerecord_index_spy/query_analyser/postgres.rb +54 -0
- data/lib/wt_activerecord_index_spy/results.html.erb +24 -0
- data/lib/wt_activerecord_index_spy/test_helpers.rb +36 -0
- data/lib/wt_activerecord_index_spy/test_models.rb +17 -0
- data/lib/wt_activerecord_index_spy/version.rb +5 -0
- data/wt_activerecord_index_spy.gemspec +43 -0
- metadata +187 -0
data/Rakefile
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
require "rubocop/rake_task"
|
9
|
+
|
10
|
+
RuboCop::RakeTask.new
|
11
|
+
|
12
|
+
task default: %i[spec rubocop]
|
13
|
+
|
14
|
+
Rake::Task["release:rubygem_push"].clear
|
15
|
+
desc "Pick up the .gem file from pkg/ and push it to Gemfury"
|
16
|
+
task "release:rubygem_push" do
|
17
|
+
# IMPORTANT: You need to have the `fury` gem installed, and you need to be logged in.
|
18
|
+
# Please DO READ about "impersonation", which is how you push to your company account instead
|
19
|
+
# of your personal account!
|
20
|
+
# https://gemfury.com/help/collaboration#impersonation
|
21
|
+
paths = Dir.glob("#{__dir__}/pkg/*.gem")
|
22
|
+
raise "Must have found only 1 .gem path, but found #{paths.inspect}" if paths.length != 1
|
23
|
+
|
24
|
+
escaped_gem_path = Shellwords.escape(paths.shift)
|
25
|
+
`fury push #{escaped_gem_path} --as=wetransfer`
|
26
|
+
end
|
27
|
+
|
28
|
+
namespace :db do
|
29
|
+
require_relative "./spec/support/test_database"
|
30
|
+
require "active_record"
|
31
|
+
require "dotenv/load"
|
32
|
+
Dotenv.load
|
33
|
+
|
34
|
+
desc "Create databases to be used in tests"
|
35
|
+
task "create" do
|
36
|
+
adapter = ENV.fetch("ADAPTER", "mysql2")
|
37
|
+
puts "Creating #{adapter}"
|
38
|
+
TestDatabase.set_env_database_url(adapter)
|
39
|
+
TestDatabase.establish_connection
|
40
|
+
ActiveRecord::Base.connection.create_database(TestDatabase.database_name)
|
41
|
+
end
|
42
|
+
|
43
|
+
desc "Drop databases to be used in tests"
|
44
|
+
task "drop" do
|
45
|
+
adapter = ENV.fetch("ADAPTER", "mysql2")
|
46
|
+
puts "Dropping #{adapter}"
|
47
|
+
TestDatabase.set_env_database_url(adapter)
|
48
|
+
TestDatabase.establish_connection
|
49
|
+
ActiveRecord::Base.connection.drop_database(TestDatabase.database_name)
|
50
|
+
end
|
51
|
+
|
52
|
+
desc "Migrate databases to be used in tests"
|
53
|
+
task "migrate" do
|
54
|
+
adapter = ENV.fetch("ADAPTER", "mysql2")
|
55
|
+
puts "Migrating #{adapter}"
|
56
|
+
TestDatabase.set_env_database_url(adapter, with_database_name: true)
|
57
|
+
TestDatabase.establish_connection
|
58
|
+
TestDatabase.run_migrations
|
59
|
+
end
|
60
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "wt_activerecord_index_spy"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "wt_activerecord_index_spy/version"
|
4
|
+
require_relative "wt_activerecord_index_spy/aggregator"
|
5
|
+
require_relative "wt_activerecord_index_spy/query_analyser"
|
6
|
+
require_relative "wt_activerecord_index_spy/query_analyser/mysql"
|
7
|
+
require_relative "wt_activerecord_index_spy/query_analyser/postgres"
|
8
|
+
require_relative "wt_activerecord_index_spy/notification_listener"
|
9
|
+
require "logger"
|
10
|
+
|
11
|
+
# This is the top level module which requires everything
|
12
|
+
module WtActiverecordIndexSpy
|
13
|
+
extend self
|
14
|
+
|
15
|
+
attr_accessor :logger
|
16
|
+
|
17
|
+
def aggregator
|
18
|
+
@aggregator ||= Aggregator.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def query_analyser
|
22
|
+
@query_analyser ||= QueryAnalyser.new
|
23
|
+
end
|
24
|
+
|
25
|
+
# rubocop:disable Metrics/MethodLength
|
26
|
+
def watch_queries(
|
27
|
+
aggregator: self.aggregator,
|
28
|
+
ignore_queries_originated_in_test_code: true,
|
29
|
+
query_analyser: self.query_analyser
|
30
|
+
)
|
31
|
+
aggregator.reset
|
32
|
+
|
33
|
+
notification_listener = NotificationListener.new(
|
34
|
+
aggregator: aggregator,
|
35
|
+
ignore_queries_originated_in_test_code: ignore_queries_originated_in_test_code,
|
36
|
+
query_analyser: query_analyser
|
37
|
+
)
|
38
|
+
|
39
|
+
subscriber = ActiveSupport::Notifications
|
40
|
+
.subscribe("sql.active_record", notification_listener)
|
41
|
+
|
42
|
+
return unless block_given?
|
43
|
+
|
44
|
+
yield
|
45
|
+
|
46
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
47
|
+
end
|
48
|
+
# rubocop:enable Metrics/MethodLength
|
49
|
+
|
50
|
+
def export_html_results(file = nil, stdout: $stdout)
|
51
|
+
aggregator.export_html_results(file, stdout: stdout)
|
52
|
+
end
|
53
|
+
|
54
|
+
def certain_results
|
55
|
+
aggregator.certain_results
|
56
|
+
end
|
57
|
+
|
58
|
+
def results
|
59
|
+
aggregator.results
|
60
|
+
end
|
61
|
+
|
62
|
+
def reset_results
|
63
|
+
aggregator.reset
|
64
|
+
end
|
65
|
+
|
66
|
+
def boot
|
67
|
+
@logger = Logger.new("/dev/null")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
WtActiverecordIndexSpy.boot
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
require "tmpdir"
|
5
|
+
|
6
|
+
module WtActiverecordIndexSpy
|
7
|
+
# This class aggregates all queries that were considered not using index.
|
8
|
+
# Since it's not possible to be sure for every query, it separates the result
|
9
|
+
# in certains and uncertains.
|
10
|
+
class Aggregator
|
11
|
+
attr_reader :results
|
12
|
+
|
13
|
+
Item = Struct.new(:identifier, :query, :origin, :certainity_level, keyword_init: true)
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@results = {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def reset
|
20
|
+
@results = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
# item: an instance of Aggregator::Item
|
24
|
+
def add(item)
|
25
|
+
@results[item.query] = item
|
26
|
+
end
|
27
|
+
|
28
|
+
def certain_results
|
29
|
+
@results.map do |_query, item|
|
30
|
+
item if item.certainity_level == :certain
|
31
|
+
end.compact
|
32
|
+
end
|
33
|
+
|
34
|
+
def uncertain_results
|
35
|
+
@results.map do |_query, item|
|
36
|
+
item if item.certainity_level == :uncertain
|
37
|
+
end.compact
|
38
|
+
end
|
39
|
+
|
40
|
+
def export_html_results(file, stdout: $stdout)
|
41
|
+
file ||= default_html_output_file
|
42
|
+
content = ERB.new(File.read(File.join(File.dirname(__FILE__), "./results.html.erb")), 0, "-")
|
43
|
+
.result_with_hash(certain_results: certain_results, uncertain_results: uncertain_results)
|
44
|
+
|
45
|
+
file.write(content)
|
46
|
+
file.close
|
47
|
+
stdout.puts "Report exported to #{file.path}"
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def default_html_output_file
|
53
|
+
File.new(
|
54
|
+
File.join(Dir.tmpdir, "wt_activerecord_index_spy-results.html"),
|
55
|
+
"w"
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WtActiverecordIndexSpy
|
4
|
+
MissingIndex = Class.new(StandardError)
|
5
|
+
|
6
|
+
# This class can be used to subscribe to an activerecord "sql.active_record"
|
7
|
+
# notification.
|
8
|
+
# It gets each query that uses a WHERE statement and runs a EXPLAIN query to
|
9
|
+
# see if it uses an index.
|
10
|
+
class NotificationListener
|
11
|
+
IGNORED_SQL = [
|
12
|
+
/^PRAGMA (?!(table_info))/,
|
13
|
+
/^SELECT currval/,
|
14
|
+
/^SELECT CAST/,
|
15
|
+
/^SELECT @@IDENTITY/,
|
16
|
+
/^SELECT @@ROWCOUNT/,
|
17
|
+
/^SAVEPOINT/,
|
18
|
+
/^ROLLBACK TO SAVEPOINT/,
|
19
|
+
/^RELEASE SAVEPOINT/,
|
20
|
+
/^SHOW max_identifier_length/,
|
21
|
+
/^SELECT @@FOREIGN_KEY_CHECKS/,
|
22
|
+
/^SET FOREIGN_KEY_CHECKS/,
|
23
|
+
/^TRUNCATE TABLE/,
|
24
|
+
/^EXPLAIN/
|
25
|
+
].freeze
|
26
|
+
|
27
|
+
attr_reader :queries_missing_index
|
28
|
+
|
29
|
+
def initialize(ignore_queries_originated_in_test_code:,
|
30
|
+
aggregator: Aggregator.new,
|
31
|
+
query_analyser: QueryAnalyser.new)
|
32
|
+
@queries_missing_index = []
|
33
|
+
@aggregator = aggregator
|
34
|
+
@query_analyser = query_analyser
|
35
|
+
@ignore_queries_originated_in_test_code = ignore_queries_originated_in_test_code
|
36
|
+
end
|
37
|
+
|
38
|
+
# TODO: refactor me pls to remove all these Rubocop warnings!
|
39
|
+
# rubocop:disable Metrics/AbcSize
|
40
|
+
# rubocop:disable Metrics/MethodLength
|
41
|
+
def call(_name, _start, _finish, _message_id, values)
|
42
|
+
query = values[:sql]
|
43
|
+
logger.debug "query: #{query}"
|
44
|
+
identifier = values[:name]
|
45
|
+
|
46
|
+
if ignore_query?(query: query, name: identifier)
|
47
|
+
logger.debug "query type ignored"
|
48
|
+
return
|
49
|
+
end
|
50
|
+
logger.debug "query type accepted"
|
51
|
+
|
52
|
+
origin = caller.find { |line| !line.include?("/gems/") }
|
53
|
+
if @ignore_queries_originated_in_test_code && query_originated_in_tests?(origin)
|
54
|
+
logger.debug "origin ignored: #{origin}"
|
55
|
+
# Hopefully, it will get the line which executed the query.
|
56
|
+
# It ignores activerecord, activesupport and other gem frames.
|
57
|
+
# Maybe there is a better way to achieve it
|
58
|
+
return
|
59
|
+
end
|
60
|
+
|
61
|
+
logger.debug "origin accepted: #{origin}"
|
62
|
+
|
63
|
+
certainity_level = @query_analyser.analyse(**values.slice(:sql, :connection, :binds))
|
64
|
+
return unless certainity_level
|
65
|
+
|
66
|
+
item = Aggregator::Item.new(
|
67
|
+
identifier: identifier,
|
68
|
+
query: query,
|
69
|
+
origin: reduce_origin(origin),
|
70
|
+
certainity_level: certainity_level
|
71
|
+
)
|
72
|
+
|
73
|
+
@aggregator.add(item)
|
74
|
+
end
|
75
|
+
# rubocop:enable Metrics/AbcSize
|
76
|
+
# rubocop:enable Metrics/MethodLength
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
# TODO: Find a better way to detect if the origin is a test file
|
81
|
+
def query_originated_in_tests?(origin)
|
82
|
+
origin.include?("spec/") ||
|
83
|
+
origin.include?("test/")
|
84
|
+
end
|
85
|
+
|
86
|
+
def ignore_query?(name:, query:)
|
87
|
+
# FIXME: this seems bad. we should probably have a better way to indicate
|
88
|
+
# the query was cached
|
89
|
+
name == "CACHE" ||
|
90
|
+
name == "SCHEMA" ||
|
91
|
+
!name ||
|
92
|
+
!query.downcase.include?("where") ||
|
93
|
+
IGNORED_SQL.any? { |r| query =~ r }
|
94
|
+
end
|
95
|
+
|
96
|
+
def reduce_origin(origin)
|
97
|
+
origin[0...origin.rindex(":")]
|
98
|
+
.split("/")[-2..-1]
|
99
|
+
.join("/")
|
100
|
+
end
|
101
|
+
|
102
|
+
def logger
|
103
|
+
WtActiverecordIndexSpy.logger
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WtActiverecordIndexSpy
|
4
|
+
# It runs an EXPLAIN query given a query and analyses the result to see if
|
5
|
+
# some index is missing.
|
6
|
+
class QueryAnalyser
|
7
|
+
def initialize
|
8
|
+
# This is a cache to not run the same EXPLAIN again
|
9
|
+
# It sets the query as key and the result (certain, uncertain) as the value
|
10
|
+
@analysed_queries = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# The sql and binds vary depend on the adapter.
|
14
|
+
# - Mysql2: sends sql complete and binds = []
|
15
|
+
# - Postregs: sends sql in a form of prepared statement and its values in binds
|
16
|
+
# rubocop:disable Metrics/MethodLength
|
17
|
+
def analyse(sql:, connection: ActiveRecord::Base.connection, binds: [])
|
18
|
+
query = sql
|
19
|
+
# TODO: this could be more intelligent to not duplicate similar queries
|
20
|
+
# with different WHERE values, example:
|
21
|
+
# - WHERE lala = 1 AND popo = 1
|
22
|
+
# - WHERE lala = 2 AND popo = 2
|
23
|
+
# Notes:
|
24
|
+
# - The Postgres adapter uses prepared statements as default, so it
|
25
|
+
# will save the queries without the values.
|
26
|
+
# - The Mysql2 adapter does not use prepared statements as default, so it
|
27
|
+
# will analyse very similar queries as described above.
|
28
|
+
return @analysed_queries[query] if @analysed_queries.key?(query)
|
29
|
+
|
30
|
+
adapter = select_adapter(connection)
|
31
|
+
|
32
|
+
# We need a thread to use a different connection that it's used by the
|
33
|
+
# application otherwise, it can change some ActiveRecord internal state
|
34
|
+
# such as number_of_affected_rows that is returned by the method
|
35
|
+
# `update_all`
|
36
|
+
Thread.new do
|
37
|
+
results = ActiveRecord::Base.connection_pool.with_connection do |conn|
|
38
|
+
conn.exec_query("EXPLAIN #{query}", "SQL", binds)
|
39
|
+
end
|
40
|
+
|
41
|
+
adapter.analyse(results).tap do |certainity_level|
|
42
|
+
@analysed_queries[query] = certainity_level
|
43
|
+
end
|
44
|
+
end.join.value
|
45
|
+
end
|
46
|
+
# rubocop:enable Metrics/MethodLength
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def select_adapter(connection)
|
51
|
+
case connection.adapter_name
|
52
|
+
when "Mysql2"
|
53
|
+
QueryAnalyser::Mysql
|
54
|
+
when "PostgreSQL"
|
55
|
+
QueryAnalyser::Postgres
|
56
|
+
else
|
57
|
+
raise NotImplementedError, "adapter: #{ActiveRecord::Base.connection.adapter_name}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WtActiverecordIndexSpy
|
4
|
+
class QueryAnalyser
|
5
|
+
# It analyses the result of an EXPLAIN query to see if any index is missing.
|
6
|
+
module Mysql
|
7
|
+
extend self
|
8
|
+
|
9
|
+
ALLOWED_EXTRA_VALUES = [
|
10
|
+
# https://bugs.mysql.com/bug.php?id=64197
|
11
|
+
"Impossible WHERE noticed after reading const tables",
|
12
|
+
"no matching row"
|
13
|
+
].freeze
|
14
|
+
|
15
|
+
def analyse(results)
|
16
|
+
results.find do |result|
|
17
|
+
certainity_level = analyse_explain(result)
|
18
|
+
|
19
|
+
break certainity_level if certainity_level
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
26
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
27
|
+
def analyse_explain(result)
|
28
|
+
type = result.fetch("type")
|
29
|
+
possible_keys = result.fetch("possible_keys")
|
30
|
+
key = result.fetch("key")
|
31
|
+
extra = result.fetch("Extra")
|
32
|
+
|
33
|
+
# more details about the result in https://dev.mysql.com/doc/refman/8.0/en/explain-output.html
|
34
|
+
return if type == "ref"
|
35
|
+
return if ALLOWED_EXTRA_VALUES.any? { |value| extra&.include?(value) }
|
36
|
+
|
37
|
+
return :certain if possible_keys.nil?
|
38
|
+
return :uncertain if possible_keys == "PRIMARY" && key.nil? && type == "ALL"
|
39
|
+
end
|
40
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
41
|
+
# rubocop: enable Metrics/PerceivedComplexity
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WtActiverecordIndexSpy
|
4
|
+
class QueryAnalyser
|
5
|
+
# It analyses the result of an EXPLAIN query to see if any index is missing.
|
6
|
+
module Postgres
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def analyse(results)
|
10
|
+
WtActiverecordIndexSpy.logger.debug("results:\n#{results.rows.join("\n")}")
|
11
|
+
|
12
|
+
full_results = results.rows.join(", ").downcase
|
13
|
+
|
14
|
+
# rubocop:disable Layout/LineLength
|
15
|
+
# Postgres sometimes uses a "seq scan" even for queries that could use an index.
|
16
|
+
# So it's almost impossible to be certain if an index is missing!
|
17
|
+
# The result of the EXPLAIN query varies depending on the state of the database
|
18
|
+
# because Postgres collects statistics from tables and decide if it's better
|
19
|
+
# using an index or not based on that.
|
20
|
+
# This is an example in a real application:
|
21
|
+
#
|
22
|
+
# [1] pry(main)> Feature.where(plan_id: 312312).explain
|
23
|
+
# Feature Load (4.0ms) SELECT "features".* FROM "features" WHERE "features"."plan_id" = $1 [["plan_id", 312312]]
|
24
|
+
# => EXPLAIN for: SELECT "features".* FROM "features" WHERE "features"."plan_id" = $1 [["plan_id", 312312]]
|
25
|
+
# QUERY PLAN
|
26
|
+
# ---------------------------------------------------------
|
27
|
+
# Seq Scan on features (cost=0.00..1.06 rows=1 width=72)
|
28
|
+
# Filter: (plan_id = 312312)
|
29
|
+
# (2 rows)
|
30
|
+
#
|
31
|
+
# [2] pry(main)> Feature.count
|
32
|
+
# (2.8ms) SELECT COUNT(*) FROM "features"
|
33
|
+
# => 5
|
34
|
+
# [3] pry(main)> Plan.count
|
35
|
+
# (2.7ms) SELECT COUNT(*) FROM "plans"
|
36
|
+
# => 2
|
37
|
+
#
|
38
|
+
####################################################################################################################
|
39
|
+
#
|
40
|
+
# [1] pry(main)> Feature.where(plan_id: 312312).explain
|
41
|
+
# Feature Load (2.3ms) SELECT "features".* FROM "features" WHERE "features"."plan_id" = $1 [["plan_id", 312312]]
|
42
|
+
# => EXPLAIN for: SELECT "features".* FROM "features" WHERE "features"."plan_id" = $1 [["plan_id", 312312]]
|
43
|
+
# QUERY PLAN
|
44
|
+
# ----------------------------------------------------------------------------------------
|
45
|
+
# Bitmap Heap Scan on features (cost=4.18..12.64 rows=4 width=72)
|
46
|
+
# Recheck Cond: (plan_id = 312312)
|
47
|
+
# -> Bitmap Index Scan on index_features_on_plan_id (cost=0.00..4.18 rows=4 width=0)
|
48
|
+
# Index Cond: (plan_id = 312312)
|
49
|
+
# rubocop:enable Layout/LineLength
|
50
|
+
return :uncertain if full_results.include?("seq scan on")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|