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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 584bbafae1893ed4f45410cc46eafe6e120e98990205b6843f2e82cc9dd3b9f0
4
- data.tar.gz: fe27bac5ebe7e38cc961c6abdffa5fc2bf0eef37f97864cd8ed23a7cb1599d70
3
+ metadata.gz: de53885b50c1f9d62d98d6713ac536f29b30ebdada947a54bde86610cbc594f2
4
+ data.tar.gz: 3211b01b3d49e290bb9a7724a3527602b6a80c3cd2d9b7416160967885c6b7dc
5
5
  SHA512:
6
- metadata.gz: 893727f91285119c0734f23f76e594f5f5085041cdbe9e3f750f785791de417a23f2b44c8f1f4a1b5392dc605e23b26d35acfe4c675f3ea93a089be6157e4711
7
- data.tar.gz: e703a05ad2758df8b778bd872b2f6eecffe05855c7f48a2a603ddbe34ed17d5914b8eef67aed3491d44c0e5843099d47d3e6783fbb5b590b15ac7ad83bb149f3
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
- ActiveRecord::Base.connection.execute("EXPLAIN ANALYZE #{raw_sql}")
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
- def initialize(line_text)
4
- @line_text = line_text
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
- 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
12
+ return nil unless sequential_scan_detected?
14
13
 
15
- missing_indexes = column_names.select do |column|
16
- !index_exists?(table_name, column)
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
- 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."
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
- # 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
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 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
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 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
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 = 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 = sql
23
+ @sql = sql
23
24
  end
24
25
 
25
26
  def analyze
26
- output = []
27
- warnings = []
28
- total_cost, rows_estimate, actual_time = nil
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 = $1.to_f
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
- if rule[:matcher].call(line)
46
+ next unless rule[:matcher].call(line)
43
47
 
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
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: output,
63
- warnings: warnings,
64
- total_cost: 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: 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.downcase.include?('cte') || line.downcase.include?('with') },
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
  }
@@ -1,5 +1,5 @@
1
1
  # lib/sql_query_analyzer/version.rb
2
2
  module SqlQueryAnalyzer
3
3
 
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sql_query_analyzer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anoob Bava