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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab9aa8ae033ce267111f508bfa46c9d18fb4b50da0f8dc175719ab40a4a416eb
4
- data.tar.gz: f6a8db3ac983476c5c26893a76f4ac127bf83a1fae1172df3f76618e2865752b
3
+ metadata.gz: 584bbafae1893ed4f45410cc46eafe6e120e98990205b6843f2e82cc9dd3b9f0
4
+ data.tar.gz: fe27bac5ebe7e38cc961c6abdffa5fc2bf0eef37f97864cd8ed23a7cb1599d70
5
5
  SHA512:
6
- metadata.gz: 7013b5496ff18a4753095df4850d3f6130567376a7157fc261848b56a970679c1da2aa55bfaf47f7423af5a4c4ad101e659137ee198342e7a291e2ea28fa25bf
7
- data.tar.gz: 4f82e5a2bac1d408dfc9eb40f5b424125cedad911102790d81509a25e102e02fa12ed4da47efa97c360d920030ecdbf9dd320100e4cd9b9d9484ef7d3e7c39b1
6
+ metadata.gz: 893727f91285119c0734f23f76e594f5f5085041cdbe9e3f750f785791de417a23f2b44c8f1f4a1b5392dc605e23b26d35acfe4c675f3ea93a089be6157e4711
7
+ data.tar.gz: e703a05ad2758df8b778bd872b2f6eecffe05855c7f48a2a603ddbe34ed17d5914b8eef67aed3491d44c0e5843099d47d3e6783fbb5b590b15ac7ad83bb149f3
@@ -0,0 +1,7 @@
1
+ module SqlQueryAnalyzer
2
+ class Execute
3
+ def self.explain_sql(raw_sql)
4
+ ActiveRecord::Base.connection.execute("EXPLAIN ANALYZE #{raw_sql}")
5
+ end
6
+ end
7
+ end
@@ -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
- engine = SqlQueryAnalyzer::SuggestionEngine.new(explain_output, to_sql)
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
- puts "\n=== EXPLAIN ANALYZE OUTPUT ===\n"
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
- # lib/sql_query_analyzer/suggestion_engine.rb
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
- suggestions = []
26
+ output = []
27
+ warnings = []
28
+ total_cost, rows_estimate, actual_time = nil
20
29
 
21
- # Rule 1: Sequential Scan
22
- if @explain_output.match?(/Seq Scan/i)
23
- suggestions << Suggestion.new(:critical, "⚡ Sequential Scan detected. Consider adding indexes to avoid full table scans.")
24
- end
30
+ @explain_output.each_with_index do |row, idx|
31
+ line = row["QUERY PLAN"]
32
+ output << line
25
33
 
26
- # Rule 2: SELECT *
27
- if @sql&.match?(/select\s+\*/i)
28
- suggestions << Suggestion.new(:warning, "🚨 Query uses SELECT *. Selecting only needed columns is more efficient.")
29
- end
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
- # Rule 3: Sort operation
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
- # Rule 4: Missing JOIN conditions
37
- if @sql&.match?(/join/i) && !@sql.match?(/on/i)
38
- suggestions << Suggestion.new(:critical, "⚡ JOIN without ON detected. May cause massive row combinations (CROSS JOIN).")
39
- end
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
- # Rule 5: High Rows estimation
42
- if @explain_output.match?(/rows=(\d+)/)
43
- rows = @explain_output.match(/rows=(\d+)/)[1].to_i
44
- if rows > 100_000
45
- suggestions << Suggestion.new(:critical, "🔥 High number of rows (#{rows}). Consider using WHERE conditions or LIMIT.")
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
- suggestions
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
@@ -1,5 +1,5 @@
1
1
  # lib/sql_query_analyzer/version.rb
2
2
  module SqlQueryAnalyzer
3
3
 
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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.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-28 00:00:00.000000000 Z
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: