sql_query_analyzer 0.1.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 +8 -0
- data/lib/sql_query_analyzer/explain_analyzer.rb +11 -9
- data/lib/sql_query_analyzer/query_plan_presenter.rb +65 -0
- data/lib/sql_query_analyzer/sequential_scan_advisor.rb +75 -0
- data/lib/sql_query_analyzer/sql_level_rules.rb +27 -0
- data/lib/sql_query_analyzer/suggestion_engine.rb +50 -26
- data/lib/sql_query_analyzer/suggestion_rules.rb +43 -0
- data/lib/sql_query_analyzer/version.rb +1 -1
- data/lib/sql_query_analyzer.rb +2 -0
- metadata +35 -2
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,18 +1,20 @@
|
|
1
1
|
# lib/sql_query_analyzer/explain_analyzer.rb
|
2
2
|
module SqlQueryAnalyzer
|
3
3
|
module ExplainAnalyzer
|
4
|
-
def explain_with_suggestions
|
5
|
-
explain_output = explain(analyze: true, verbose: true, costs: true, buffers: true, timing: true)
|
4
|
+
def explain_with_suggestions(run: false)
|
6
5
|
|
7
|
-
|
6
|
+
unless self.is_a?(ActiveRecord::Relation)
|
7
|
+
puts "⚠️ Not an ActiveRecord Relation. Skipping explain_with_suggestions."
|
8
|
+
return
|
9
|
+
end
|
10
|
+
|
11
|
+
raw_sql = self.to_sql
|
12
|
+
|
13
|
+
explain_output = SqlQueryAnalyzer::Execute.explain_sql(raw_sql, run)
|
14
|
+
engine = SqlQueryAnalyzer::SuggestionEngine.new(explain_output, raw_sql)
|
8
15
|
suggestions = engine.analyze
|
9
16
|
|
10
|
-
|
11
|
-
puts explain_output
|
12
|
-
puts "\n=== SUGGESTIONS ===\n"
|
13
|
-
suggestions.each do |suggestion|
|
14
|
-
puts "[#{suggestion.severity.to_s.upcase}] #{suggestion.message}"
|
15
|
-
end
|
17
|
+
nil
|
16
18
|
rescue => e
|
17
19
|
puts "Error analyzing query: #{e.message}"
|
18
20
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'pastel'
|
2
|
+
|
3
|
+
module SqlQueryAnalyzer
|
4
|
+
class QueryPlanPresenter
|
5
|
+
def initialize(output:, warnings:, total_cost:, rows_estimate:, actual_time:)
|
6
|
+
@output = output
|
7
|
+
@warnings = warnings
|
8
|
+
@total_cost = total_cost
|
9
|
+
@rows_estimate = rows_estimate
|
10
|
+
@actual_time = actual_time
|
11
|
+
@pastel = Pastel.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def display
|
15
|
+
puts @pastel.bold("\n🔍 QUERY PLAN:")
|
16
|
+
@output.each_with_index do |line, idx|
|
17
|
+
puts " #{@pastel.cyan("#{idx + 1}:")} #{line}"
|
18
|
+
end
|
19
|
+
|
20
|
+
puts @pastel.bold("\n📊 Query Metrics:")
|
21
|
+
puts " 💰 Total Cost: #{@pastel.green(@total_cost)}" if @total_cost
|
22
|
+
puts " 📈 Rows Estimate: #{@pastel.blue(@rows_estimate)}" if @rows_estimate
|
23
|
+
puts " ⏱️ Actual Time: #{@pastel.magenta("#{@actual_time} ms")}" if @actual_time
|
24
|
+
|
25
|
+
if @warnings.any?
|
26
|
+
puts @pastel.bold("\n🚩 Warnings and Suggestions:")
|
27
|
+
@warnings.each do |warn|
|
28
|
+
puts " #{@pastel.yellow("Line #{warn[:line_number]}:")} #{warn[:line_text]}"
|
29
|
+
puts " 👉 #{colorize_by_severity(warn[:suggestion])}"
|
30
|
+
end
|
31
|
+
else
|
32
|
+
puts @pastel.green("\n✅ No immediate problems detected in the query plan.")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.classify_cost(cost)
|
37
|
+
return unless cost
|
38
|
+
pastel = Pastel.new
|
39
|
+
|
40
|
+
case cost
|
41
|
+
when 0..300
|
42
|
+
puts pastel.green("\n✅ Query cost is LOW. All good!")
|
43
|
+
when 301..1000
|
44
|
+
puts pastel.yellow("\n🧐 Query cost is MODERATE. May benefit from optimizations.")
|
45
|
+
else
|
46
|
+
puts pastel.red.bold("\n🛑 Query cost is HIGH. Recommend tuning immediately!")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def colorize_by_severity(suggestion)
|
53
|
+
case suggestion.severity
|
54
|
+
when :critical
|
55
|
+
@pastel.red.bold(suggestion.to_s)
|
56
|
+
when :warning
|
57
|
+
@pastel.yellow.bold(suggestion.to_s)
|
58
|
+
when :info
|
59
|
+
@pastel.cyan(suggestion.to_s)
|
60
|
+
else
|
61
|
+
suggestion.to_s
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module SqlQueryAnalyzer
|
2
|
+
class SequentialScanAdvisor
|
3
|
+
attr_reader :query_plan
|
4
|
+
|
5
|
+
def initialize(query_plan)
|
6
|
+
@query_plan = query_plan
|
7
|
+
end
|
8
|
+
|
9
|
+
def enhanced_message
|
10
|
+
table_name, column_names = extract_table_and_columns
|
11
|
+
|
12
|
+
return nil unless sequential_scan_detected?
|
13
|
+
|
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."
|
18
|
+
end
|
19
|
+
|
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(', ')}"
|
25
|
+
end
|
26
|
+
messages.join("\n")
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def sequential_scan_detected?
|
32
|
+
query_plan.include?("Seq Scan on")
|
33
|
+
end
|
34
|
+
|
35
|
+
def extract_table_and_columns
|
36
|
+
table_name = query_plan.match(/Seq Scan on (\w+)/)&.captures&.first
|
37
|
+
[ table_name, extract_columns_from_filter ]
|
38
|
+
end
|
39
|
+
|
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
|
63
|
+
end
|
64
|
+
|
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 }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module SqlQueryAnalyzer
|
2
|
+
class SqlLevelRules
|
3
|
+
def self.evaluate(sql)
|
4
|
+
return [] unless sql
|
5
|
+
|
6
|
+
warnings = []
|
7
|
+
|
8
|
+
if sql.match?(/select\s+\*/i)
|
9
|
+
warnings << {
|
10
|
+
line_number: 'N/A',
|
11
|
+
line_text: 'SELECT *',
|
12
|
+
suggestion: Suggestion.new(:warning, "🚨 Query uses SELECT *. Specify only needed columns for performance.")
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
if sql.match?(/join/i) && !sql.match?(/on/i)
|
17
|
+
warnings << {
|
18
|
+
line_number: 'N/A',
|
19
|
+
line_text: 'JOIN without ON',
|
20
|
+
suggestion: Suggestion.new(:critical, "⚡ JOIN without ON detected. May cause massive row combinations (CROSS JOIN).")
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
warnings
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -1,52 +1,76 @@
|
|
1
|
-
|
1
|
+
require_relative 'suggestion_rules'
|
2
|
+
require_relative 'query_plan_presenter'
|
3
|
+
require_relative 'sql_level_rules'
|
4
|
+
require_relative 'sequential_scan_advisor'
|
5
|
+
|
2
6
|
module SqlQueryAnalyzer
|
3
7
|
class Suggestion
|
4
8
|
attr_reader :severity, :message
|
5
9
|
|
6
10
|
def initialize(severity, message)
|
7
11
|
@severity = severity
|
8
|
-
@message
|
12
|
+
@message = message
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
"[#{severity.to_s.upcase}] #{message}"
|
9
17
|
end
|
10
18
|
end
|
11
19
|
|
12
20
|
class SuggestionEngine
|
13
21
|
def initialize(explain_output, sql = nil)
|
14
22
|
@explain_output = explain_output
|
15
|
-
@sql
|
23
|
+
@sql = sql
|
16
24
|
end
|
17
25
|
|
18
26
|
def analyze
|
19
|
-
|
27
|
+
warnings = []
|
28
|
+
total_cost = nil
|
29
|
+
rows_estimate = nil
|
30
|
+
actual_time = nil
|
20
31
|
|
21
|
-
#
|
22
|
-
|
23
|
-
suggestions << Suggestion.new(:critical, "⚡ Sequential Scan detected. Consider adding indexes to avoid full table scans.")
|
24
|
-
end
|
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")
|
25
34
|
|
26
|
-
|
27
|
-
|
28
|
-
suggestions << Suggestion.new(:warning, "🚨 Query uses SELECT *. Selecting only needed columns is more efficient.")
|
29
|
-
end
|
35
|
+
@explain_output.each_with_index do |row, idx|
|
36
|
+
line = row["QUERY PLAN"]
|
30
37
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
38
|
+
# Capture cost & estimates
|
39
|
+
if line =~ /cost=\d+\.\d+\.\.(\d+\.\d+) rows=(\d+)/
|
40
|
+
total_cost = $1.to_f
|
41
|
+
rows_estimate = $2.to_i
|
42
|
+
end
|
43
|
+
actual_time ||= $1.to_f if line =~ /actual time=(\d+\.\d+)/
|
35
44
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
45
|
+
SuggestionRules.all.each do |rule|
|
46
|
+
next unless rule[:matcher].call(line)
|
47
|
+
|
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
|
52
|
+
end
|
40
53
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
54
|
+
warnings << {
|
55
|
+
line_number: idx + 1,
|
56
|
+
line_text: line,
|
57
|
+
suggestion: suggestion
|
58
|
+
}
|
46
59
|
end
|
47
60
|
end
|
48
61
|
|
49
|
-
|
62
|
+
warnings.concat(SqlLevelRules.evaluate(@sql))
|
63
|
+
|
64
|
+
presenter = QueryPlanPresenter.new(
|
65
|
+
output: @explain_output.map { |r| r["QUERY PLAN"] },
|
66
|
+
warnings: warnings,
|
67
|
+
total_cost: total_cost,
|
68
|
+
rows_estimate: rows_estimate,
|
69
|
+
actual_time: actual_time
|
70
|
+
)
|
71
|
+
|
72
|
+
presenter.display
|
73
|
+
QueryPlanPresenter.classify_cost(total_cost)
|
50
74
|
end
|
51
75
|
end
|
52
76
|
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module SqlQueryAnalyzer
|
2
|
+
class SuggestionRules
|
3
|
+
def self.all
|
4
|
+
[
|
5
|
+
{
|
6
|
+
matcher: ->(line) { line.include?('Seq Scan') },
|
7
|
+
severity: :critical,
|
8
|
+
message: "⚡ Sequential Scan detected. Add appropriate indexes to avoid full table scans."
|
9
|
+
},
|
10
|
+
{
|
11
|
+
matcher: ->(line) { line.include?('Nested Loop') },
|
12
|
+
severity: :warning,
|
13
|
+
message: "🌀 Nested Loop detected. Ensure JOINs are optimized and indexed."
|
14
|
+
},
|
15
|
+
{
|
16
|
+
matcher: ->(line) { line.include?('Bitmap Heap Scan') },
|
17
|
+
severity: :info,
|
18
|
+
message: "📦 Bitmap Heap Scan used. Acceptable but check if an index-only scan is possible."
|
19
|
+
},
|
20
|
+
{
|
21
|
+
matcher: ->(line) { line.include?('Materialize') },
|
22
|
+
severity: :warning,
|
23
|
+
message: "📄 Materialize detected. May cause extra memory usage if result sets are large."
|
24
|
+
},
|
25
|
+
{
|
26
|
+
matcher: ->(line) { line.include?('Hash Join') },
|
27
|
+
severity: :info,
|
28
|
+
message: "🔗 Hash Join used. Generally fast for large datasets, but check hash table memory usage."
|
29
|
+
},
|
30
|
+
{
|
31
|
+
matcher: ->(line) { line.include?('Merge Join') },
|
32
|
+
severity: :info,
|
33
|
+
message: "🔀 Merge Join used. Efficient if input is sorted properly; check indexes."
|
34
|
+
},
|
35
|
+
{
|
36
|
+
matcher: ->(line) { line&.match?(/^\s*WITH\s+\w+\s+AS\s*\(/i) },
|
37
|
+
severity: :info,
|
38
|
+
message: "🔧 CTE usage detected. Be aware CTEs can materialize (costly) in Postgres < 12."
|
39
|
+
}
|
40
|
+
]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/sql_query_analyzer.rb
CHANGED
@@ -5,6 +5,8 @@ require "active_record"
|
|
5
5
|
require "sql_query_analyzer/version"
|
6
6
|
require "sql_query_analyzer/suggestion_engine"
|
7
7
|
require "sql_query_analyzer/explain_analyzer"
|
8
|
+
require "sql_query_analyzer/execute"
|
9
|
+
require "sql_query_analyzer/sequential_scan_advisor"
|
8
10
|
|
9
11
|
module SqlQueryAnalyzer
|
10
12
|
# Future configurations can go here
|
metadata
CHANGED
@@ -1,15 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sql_query_analyzer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Anoob Bava
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-04-
|
11
|
+
date: 2025-04-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: pastel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.8.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.8.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: rspec
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -33,8 +61,13 @@ extensions: []
|
|
33
61
|
extra_rdoc_files: []
|
34
62
|
files:
|
35
63
|
- lib/sql_query_analyzer.rb
|
64
|
+
- lib/sql_query_analyzer/execute.rb
|
36
65
|
- lib/sql_query_analyzer/explain_analyzer.rb
|
66
|
+
- lib/sql_query_analyzer/query_plan_presenter.rb
|
67
|
+
- lib/sql_query_analyzer/sequential_scan_advisor.rb
|
68
|
+
- lib/sql_query_analyzer/sql_level_rules.rb
|
37
69
|
- lib/sql_query_analyzer/suggestion_engine.rb
|
70
|
+
- lib/sql_query_analyzer/suggestion_rules.rb
|
38
71
|
- lib/sql_query_analyzer/version.rb
|
39
72
|
homepage: https://github.com/anoobbava/sql_query_analyzer
|
40
73
|
licenses:
|