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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab9aa8ae033ce267111f508bfa46c9d18fb4b50da0f8dc175719ab40a4a416eb
4
- data.tar.gz: f6a8db3ac983476c5c26893a76f4ac127bf83a1fae1172df3f76618e2865752b
3
+ metadata.gz: de53885b50c1f9d62d98d6713ac536f29b30ebdada947a54bde86610cbc594f2
4
+ data.tar.gz: 3211b01b3d49e290bb9a7724a3527602b6a80c3cd2d9b7416160967885c6b7dc
5
5
  SHA512:
6
- metadata.gz: 7013b5496ff18a4753095df4850d3f6130567376a7157fc261848b56a970679c1da2aa55bfaf47f7423af5a4c4ad101e659137ee198342e7a291e2ea28fa25bf
7
- data.tar.gz: 4f82e5a2bac1d408dfc9eb40f5b424125cedad911102790d81509a25e102e02fa12ed4da47efa97c360d920030ecdbf9dd320100e4cd9b9d9484ef7d3e7c39b1
6
+ metadata.gz: 8bbd2f6b1c5cc37034909e5f4a7e24aefc77e2d7d5719ee0d2d5908873249a01ba6ec9d32641658d0d316119ca9b30ce1da0cfbf31d43db02b49930321a92288
7
+ data.tar.gz: 2c016cca40185f82db401a0a582f5746c15072d6726ebcf7f6ad4e2ff228aecff6e863c00a08ff1da0dce5c7b43d9549e3b047dded9da5255af6f77b0576396c
@@ -0,0 +1,8 @@
1
+ module SqlQueryAnalyzer
2
+ class Execute
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)
6
+ end
7
+ end
8
+ end
@@ -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
- 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, run)
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,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
- # 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
+ 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 = 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 = sql
23
+ @sql = sql
16
24
  end
17
25
 
18
26
  def analyze
19
- suggestions = []
27
+ warnings = []
28
+ total_cost = nil
29
+ rows_estimate = nil
30
+ actual_time = nil
20
31
 
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
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
- # 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
35
+ @explain_output.each_with_index do |row, idx|
36
+ line = row["QUERY PLAN"]
30
37
 
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
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
- # 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
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
- # 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.")
54
+ warnings << {
55
+ line_number: idx + 1,
56
+ line_text: line,
57
+ suggestion: suggestion
58
+ }
46
59
  end
47
60
  end
48
61
 
49
- suggestions
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
@@ -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.1"
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.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-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: