sql_query_analyzer 0.1.0 → 0.2.0
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 +7 -0
- data/lib/sql_query_analyzer/explain_analyzer.rb +10 -8
- data/lib/sql_query_analyzer/query_plan_presenter.rb +65 -0
- data/lib/sql_query_analyzer/sequential_scan_advisor.rb +54 -0
- data/lib/sql_query_analyzer/sql_level_rules.rb +27 -0
- data/lib/sql_query_analyzer/suggestion_engine.rb +45 -24
- 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: 584bbafae1893ed4f45410cc46eafe6e120e98990205b6843f2e82cc9dd3b9f0
|
4
|
+
data.tar.gz: fe27bac5ebe7e38cc961c6abdffa5fc2bf0eef37f97864cd8ed23a7cb1599d70
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 893727f91285119c0734f23f76e594f5f5085041cdbe9e3f750f785791de417a23f2b44c8f1f4a1b5392dc605e23b26d35acfe4c675f3ea93a089be6157e4711
|
7
|
+
data.tar.gz: e703a05ad2758df8b778bd872b2f6eecffe05855c7f48a2a603ddbe34ed17d5914b8eef67aed3491d44c0e5843099d47d3e6783fbb5b590b15ac7ad83bb149f3
|
@@ -2,17 +2,19 @@
|
|
2
2
|
module SqlQueryAnalyzer
|
3
3
|
module ExplainAnalyzer
|
4
4
|
def explain_with_suggestions
|
5
|
-
explain_output = explain(analyze: true, verbose: true, costs: true, buffers: true, timing: true)
|
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)
|
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,54 @@
|
|
1
|
+
module SqlQueryAnalyzer
|
2
|
+
class SequentialScanAdvisor
|
3
|
+
def initialize(line_text)
|
4
|
+
@line_text = line_text
|
5
|
+
end
|
6
|
+
|
7
|
+
def enhanced_message
|
8
|
+
table_name, column_names = extract_table_and_columns
|
9
|
+
return nil unless table_name
|
10
|
+
|
11
|
+
if column_names.empty?
|
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
|
14
|
+
|
15
|
+
missing_indexes = column_names.select do |column|
|
16
|
+
!index_exists?(table_name, column)
|
17
|
+
end
|
18
|
+
|
19
|
+
if missing_indexes.any?
|
20
|
+
"⚡ Sequential Scan detected on '#{table_name}'. Consider adding indexes on: #{missing_indexes.join(', ')}."
|
21
|
+
else
|
22
|
+
"⚡ Sequential Scan detected on '#{table_name}', but indexes seem to exist. Might be due to low table size or outdated stats."
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def extract_table_and_columns
|
29
|
+
# Simplified pattern: "Seq Scan on users (cost=...)"
|
30
|
+
if @line_text =~ /Seq Scan on (\w+)/
|
31
|
+
table_name = $1
|
32
|
+
columns = extract_filter_columns(@line_text)
|
33
|
+
[table_name, columns]
|
34
|
+
else
|
35
|
+
[nil, []]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def extract_filter_columns(text)
|
40
|
+
# Example EXPLAIN line might include: "Filter: (email = 'x')"
|
41
|
+
text.scan(/Filter: \((.*?)\)/).flatten
|
42
|
+
.flat_map { |f| f.scan(/\b(\w+)\s*=/) }
|
43
|
+
.flatten
|
44
|
+
end
|
45
|
+
|
46
|
+
def index_exists?(table_name, column)
|
47
|
+
ActiveRecord::Base.connection.indexes(table_name).any? do |idx|
|
48
|
+
idx.columns.include?(column)
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
false # Assume false in case of table missing or dev env differences
|
52
|
+
end
|
53
|
+
end
|
54
|
+
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,4 +1,7 @@
|
|
1
|
-
|
1
|
+
require_relative 'suggestion_rules'
|
2
|
+
require_relative 'query_plan_presenter'
|
3
|
+
require_relative 'sql_level_rules'
|
4
|
+
|
2
5
|
module SqlQueryAnalyzer
|
3
6
|
class Suggestion
|
4
7
|
attr_reader :severity, :message
|
@@ -7,6 +10,10 @@ module SqlQueryAnalyzer
|
|
7
10
|
@severity = severity
|
8
11
|
@message = message
|
9
12
|
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"[#{severity.to_s.upcase}] #{message}"
|
16
|
+
end
|
10
17
|
end
|
11
18
|
|
12
19
|
class SuggestionEngine
|
@@ -16,37 +23,51 @@ module SqlQueryAnalyzer
|
|
16
23
|
end
|
17
24
|
|
18
25
|
def analyze
|
19
|
-
|
26
|
+
output = []
|
27
|
+
warnings = []
|
28
|
+
total_cost, rows_estimate, actual_time = nil
|
20
29
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
end
|
30
|
+
@explain_output.each_with_index do |row, idx|
|
31
|
+
line = row["QUERY PLAN"]
|
32
|
+
output << line
|
25
33
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
34
|
+
if line =~ /cost=\d+\.\d+\.\.(\d+\.\d+) rows=(\d+)/
|
35
|
+
total_cost = $1.to_f
|
36
|
+
rows_estimate = $2.to_i
|
37
|
+
end
|
30
38
|
|
31
|
-
|
32
|
-
if @explain_output.match?(/Sort/i)
|
33
|
-
suggestions << Suggestion.new(:info, "📈 Sort detected. Consider adding an index to support ORDER BY.")
|
34
|
-
end
|
39
|
+
actual_time ||= $1.to_f if line =~ /actual time=(\d+\.\d+)/
|
35
40
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
41
|
+
SuggestionRules.all.each do |rule|
|
42
|
+
if rule[:matcher].call(line)
|
43
|
+
|
44
|
+
suggestion = Suggestion.new(rule[:severity], rule[:message])
|
45
|
+
if rule[:message].include?("Sequential Scan")
|
46
|
+
dynamic_msg = SequentialScanAdvisor.new(line).enhanced_message
|
47
|
+
suggestion = Suggestion.new(rule[:severity], dynamic_msg) if dynamic_msg
|
48
|
+
end
|
40
49
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
50
|
+
warnings << {
|
51
|
+
line_number: idx + 1,
|
52
|
+
line_text: line,
|
53
|
+
suggestion: suggestion
|
54
|
+
}
|
55
|
+
end
|
46
56
|
end
|
47
57
|
end
|
48
58
|
|
49
|
-
|
59
|
+
warnings.concat(SqlLevelRules.evaluate(@sql))
|
60
|
+
|
61
|
+
presenter = QueryPlanPresenter.new(
|
62
|
+
output: output,
|
63
|
+
warnings: warnings,
|
64
|
+
total_cost: total_cost,
|
65
|
+
rows_estimate: rows_estimate,
|
66
|
+
actual_time: actual_time
|
67
|
+
)
|
68
|
+
|
69
|
+
presenter.display
|
70
|
+
QueryPlanPresenter.classify_cost(total_cost)
|
50
71
|
end
|
51
72
|
end
|
52
73
|
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.downcase.include?('cte') || line.downcase.include?('with') },
|
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.
|
4
|
+
version: 0.2.0
|
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:
|