sql_query_analyzer 0.2.0 → 0.2.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 +4 -4
- data/lib/sql_query_analyzer/execute.rb +3 -2
- data/lib/sql_query_analyzer/explain_analyzer.rb +2 -2
- data/lib/sql_query_analyzer/sequential_scan_advisor.rb +52 -31
- data/lib/sql_query_analyzer/suggestion_engine.rb +27 -24
- data/lib/sql_query_analyzer/suggestion_rules.rb +1 -1
- data/lib/sql_query_analyzer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: de53885b50c1f9d62d98d6713ac536f29b30ebdada947a54bde86610cbc594f2
|
4
|
+
data.tar.gz: 3211b01b3d49e290bb9a7724a3527602b6a80c3cd2d9b7416160967885c6b7dc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8bbd2f6b1c5cc37034909e5f4a7e24aefc77e2d7d5719ee0d2d5908873249a01ba6ec9d32641658d0d316119ca9b30ce1da0cfbf31d43db02b49930321a92288
|
7
|
+
data.tar.gz: 2c016cca40185f82db401a0a582f5746c15072d6726ebcf7f6ad4e2ff228aecff6e863c00a08ff1da0dce5c7b43d9549e3b047dded9da5255af6f77b0576396c
|
@@ -1,7 +1,8 @@
|
|
1
1
|
module SqlQueryAnalyzer
|
2
2
|
class Execute
|
3
|
-
def self.explain_sql(raw_sql)
|
4
|
-
|
3
|
+
def self.explain_sql(raw_sql, run)
|
4
|
+
query_with_options = run ? "EXPLAIN ANALYZE #{raw_sql}" : "EXPLAIN #{raw_sql}"
|
5
|
+
ActiveRecord::Base.connection.execute(query_with_options)
|
5
6
|
end
|
6
7
|
end
|
7
8
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# lib/sql_query_analyzer/explain_analyzer.rb
|
2
2
|
module SqlQueryAnalyzer
|
3
3
|
module ExplainAnalyzer
|
4
|
-
def explain_with_suggestions
|
4
|
+
def explain_with_suggestions(run: false)
|
5
5
|
|
6
6
|
unless self.is_a?(ActiveRecord::Relation)
|
7
7
|
puts "⚠️ Not an ActiveRecord Relation. Skipping explain_with_suggestions."
|
@@ -10,7 +10,7 @@ module SqlQueryAnalyzer
|
|
10
10
|
|
11
11
|
raw_sql = self.to_sql
|
12
12
|
|
13
|
-
explain_output = SqlQueryAnalyzer::Execute.explain_sql(raw_sql)
|
13
|
+
explain_output = SqlQueryAnalyzer::Execute.explain_sql(raw_sql, run)
|
14
14
|
engine = SqlQueryAnalyzer::SuggestionEngine.new(explain_output, raw_sql)
|
15
15
|
suggestions = engine.analyze
|
16
16
|
|
@@ -1,54 +1,75 @@
|
|
1
1
|
module SqlQueryAnalyzer
|
2
2
|
class SequentialScanAdvisor
|
3
|
-
|
4
|
-
|
3
|
+
attr_reader :query_plan
|
4
|
+
|
5
|
+
def initialize(query_plan)
|
6
|
+
@query_plan = query_plan
|
5
7
|
end
|
6
8
|
|
7
9
|
def enhanced_message
|
8
10
|
table_name, column_names = extract_table_and_columns
|
9
|
-
return nil unless table_name
|
10
11
|
|
11
|
-
|
12
|
-
return "⚡ Sequential Scan detected on '#{table_name}', but no filter condition found. Likely a full table read (e.g., SELECT *), or small table size makes index use unnecessary."
|
13
|
-
end
|
12
|
+
return nil unless sequential_scan_detected?
|
14
13
|
|
15
|
-
|
16
|
-
|
14
|
+
if column_names.empty?
|
15
|
+
return "👉 [CRITICAL] ⚡ Sequential Scan detected on '#{table_name}', " \
|
16
|
+
"but no filter condition found. Likely a full table read " \
|
17
|
+
"(e.g., SELECT *), or small table size makes index use unnecessary."
|
17
18
|
end
|
18
19
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
20
|
+
messages = []
|
21
|
+
messages << "👉 [CRITICAL] ⚡ Sequential Scan detected on '#{table_name}', " \
|
22
|
+
"and filter involves columns: #{column_names.join(', ')}."
|
23
|
+
if missing_composite_index?(table_name, column_names)
|
24
|
+
messages << "💡 Consider adding a composite index on: #{column_names.join(', ')}"
|
23
25
|
end
|
26
|
+
messages.join("\n")
|
24
27
|
end
|
25
28
|
|
26
29
|
private
|
27
30
|
|
31
|
+
def sequential_scan_detected?
|
32
|
+
query_plan.include?("Seq Scan on")
|
33
|
+
end
|
34
|
+
|
28
35
|
def extract_table_and_columns
|
29
|
-
|
30
|
-
|
31
|
-
table_name = $1
|
32
|
-
columns = extract_filter_columns(@line_text)
|
33
|
-
[table_name, columns]
|
34
|
-
else
|
35
|
-
[nil, []]
|
36
|
-
end
|
36
|
+
table_name = query_plan.match(/Seq Scan on (\w+)/)&.captures&.first
|
37
|
+
[ table_name, extract_columns_from_filter ]
|
37
38
|
end
|
38
39
|
|
39
|
-
def
|
40
|
-
#
|
41
|
-
|
42
|
-
|
43
|
-
|
40
|
+
def extract_columns_from_filter
|
41
|
+
# Grab the Filter line out of the full plan
|
42
|
+
filter_line = query_plan.lines.find { |l| l.strip.start_with?("Filter:") }
|
43
|
+
return [] unless filter_line
|
44
|
+
|
45
|
+
# Remove the "Filter:" prefix and any Postgres typecasts
|
46
|
+
cleaned = filter_line
|
47
|
+
.sub("Filter:", "")
|
48
|
+
.gsub(/::[a-zA-Z_ ]+/, "")
|
49
|
+
.downcase
|
50
|
+
|
51
|
+
# Pull actual column names for this table from the schema
|
52
|
+
table_name = query_plan.match(/Seq Scan on (\w+)/)&.captures&.first
|
53
|
+
return [] unless table_name
|
54
|
+
schema_cols = ActiveRecord::Base
|
55
|
+
.connection
|
56
|
+
.columns(table_name.to_sym)
|
57
|
+
.map(&:name)
|
58
|
+
.map(&:downcase)
|
59
|
+
|
60
|
+
# Of all schema columns, pick those mentioned in the cleaned filter
|
61
|
+
extracted = schema_cols.select { |col| cleaned.include?(col) }.uniq
|
62
|
+
extracted
|
44
63
|
end
|
45
64
|
|
46
|
-
def
|
47
|
-
ActiveRecord::Base
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
65
|
+
def missing_composite_index?(table_name, columns)
|
66
|
+
existing = ActiveRecord::Base
|
67
|
+
.connection
|
68
|
+
.indexes(table_name.to_sym)
|
69
|
+
.map(&:columns)
|
70
|
+
.map { |cols| cols.map(&:downcase) }
|
71
|
+
|
72
|
+
!existing.any? { |idx_cols| idx_cols == columns || idx_cols.first(columns.length) == columns }
|
52
73
|
end
|
53
74
|
end
|
54
75
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
require_relative 'suggestion_rules'
|
2
2
|
require_relative 'query_plan_presenter'
|
3
3
|
require_relative 'sql_level_rules'
|
4
|
+
require_relative 'sequential_scan_advisor'
|
4
5
|
|
5
6
|
module SqlQueryAnalyzer
|
6
7
|
class Suggestion
|
@@ -8,7 +9,7 @@ module SqlQueryAnalyzer
|
|
8
9
|
|
9
10
|
def initialize(severity, message)
|
10
11
|
@severity = severity
|
11
|
-
@message
|
12
|
+
@message = message
|
12
13
|
end
|
13
14
|
|
14
15
|
def to_s
|
@@ -19,51 +20,53 @@ module SqlQueryAnalyzer
|
|
19
20
|
class SuggestionEngine
|
20
21
|
def initialize(explain_output, sql = nil)
|
21
22
|
@explain_output = explain_output
|
22
|
-
@sql
|
23
|
+
@sql = sql
|
23
24
|
end
|
24
25
|
|
25
26
|
def analyze
|
26
|
-
|
27
|
-
|
28
|
-
|
27
|
+
warnings = []
|
28
|
+
total_cost = nil
|
29
|
+
rows_estimate = nil
|
30
|
+
actual_time = nil
|
31
|
+
|
32
|
+
# Build the full plan text (all lines) so advisors can see Filters, etc.
|
33
|
+
full_plan = @explain_output.map { |row| row["QUERY PLAN"] }.join("\n")
|
29
34
|
|
30
35
|
@explain_output.each_with_index do |row, idx|
|
31
36
|
line = row["QUERY PLAN"]
|
32
|
-
output << line
|
33
37
|
|
38
|
+
# Capture cost & estimates
|
34
39
|
if line =~ /cost=\d+\.\d+\.\.(\d+\.\d+) rows=(\d+)/
|
35
|
-
total_cost
|
40
|
+
total_cost = $1.to_f
|
36
41
|
rows_estimate = $2.to_i
|
37
42
|
end
|
38
|
-
|
39
43
|
actual_time ||= $1.to_f if line =~ /actual time=(\d+\.\d+)/
|
40
44
|
|
41
45
|
SuggestionRules.all.each do |rule|
|
42
|
-
|
46
|
+
next unless rule[:matcher].call(line)
|
43
47
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
end
|
49
|
-
|
50
|
-
warnings << {
|
51
|
-
line_number: idx + 1,
|
52
|
-
line_text: line,
|
53
|
-
suggestion: suggestion
|
54
|
-
}
|
48
|
+
suggestion = Suggestion.new(rule[:severity], rule[:message])
|
49
|
+
if rule[:message].include?("Sequential Scan")
|
50
|
+
dynamic = SequentialScanAdvisor.new(full_plan).enhanced_message
|
51
|
+
suggestion = Suggestion.new(rule[:severity], dynamic) if dynamic
|
55
52
|
end
|
53
|
+
|
54
|
+
warnings << {
|
55
|
+
line_number: idx + 1,
|
56
|
+
line_text: line,
|
57
|
+
suggestion: suggestion
|
58
|
+
}
|
56
59
|
end
|
57
60
|
end
|
58
61
|
|
59
62
|
warnings.concat(SqlLevelRules.evaluate(@sql))
|
60
63
|
|
61
64
|
presenter = QueryPlanPresenter.new(
|
62
|
-
output:
|
63
|
-
warnings:
|
64
|
-
total_cost:
|
65
|
+
output: @explain_output.map { |r| r["QUERY PLAN"] },
|
66
|
+
warnings: warnings,
|
67
|
+
total_cost: total_cost,
|
65
68
|
rows_estimate: rows_estimate,
|
66
|
-
actual_time:
|
69
|
+
actual_time: actual_time
|
67
70
|
)
|
68
71
|
|
69
72
|
presenter.display
|
@@ -33,7 +33,7 @@ module SqlQueryAnalyzer
|
|
33
33
|
message: "🔀 Merge Join used. Efficient if input is sorted properly; check indexes."
|
34
34
|
},
|
35
35
|
{
|
36
|
-
matcher: ->(line) { line
|
36
|
+
matcher: ->(line) { line&.match?(/^\s*WITH\s+\w+\s+AS\s*\(/i) },
|
37
37
|
severity: :info,
|
38
38
|
message: "🔧 CTE usage detected. Be aware CTEs can materialize (costly) in Postgres < 12."
|
39
39
|
}
|