Module: Profiler::ExplainRunner

Defined in:
lib/profiler/explain_runner.rb

Overview

Shared service for running EXPLAIN ANALYZE on a stored query. Used by both the HTTP API controller and the MCP explain_query tool.

Class Method Summary collapse

Class Method Details

.build_explain_statement(sql, adapter) ⇒ Object



72
73
74
75
76
77
78
79
80
# File 'lib/profiler/explain_runner.rb', line 72

def self.build_explain_statement(sql, adapter)
  if adapter.include?("postgresql")
    ["EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{sql}", "json"]
  elsif adapter.include?("mysql")
    ["EXPLAIN FORMAT=JSON #{sql}", "json"]
  else
    ["EXPLAIN QUERY PLAN #{sql}", "text"]
  end
end

.reconstruct_sql(sql, binds, conn, adapter) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/profiler/explain_runner.rb', line 53

def self.reconstruct_sql(sql, binds, conn, adapter)
  return sql if binds.empty?

  if adapter.include?("postgresql")
    result = sql.dup
    binds.each_with_index do |value, i|
      result = result.gsub("$#{i + 1}", conn.quote(value))
    end
    result
  else
    # MySQL / SQLite: replace ? sequentially
    result = sql.dup
    binds.each do |value|
      result = result.sub("?", conn.quote(value))
    end
    result
  end
end

.run(profile_token, query_index) ⇒ Hash

Returns { result:, format: “json”|“text”, adapter: String } or raises ArgumentError / RuntimeError.

Parameters:

  • profile_token (String)
  • query_index (Integer)

Returns:

  • (Hash)

    { result:, format: “json”|“text”, adapter: String } or raises ArgumentError / RuntimeError

Raises:

  • (ArgumentError)


11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/profiler/explain_runner.rb', line 11

def self.run(profile_token, query_index)
  unless Profiler.configuration.enabled
    raise SecurityError, "EXPLAIN is only available when the profiler is enabled"
  end

  profile = Profiler.storage.load(profile_token)
  raise ArgumentError, "Profile not found: #{profile_token}" unless profile

  db_data = profile.collector_data("database")
  raise ArgumentError, "No database data in this profile" unless db_data && db_data["queries"]

  queries = db_data["queries"]
  query_index = query_index.to_i
  unless query_index >= 0 && query_index < queries.size
    raise ArgumentError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
  end

  query = queries[query_index]
  sql   = query["sql"].to_s
  binds = Array(query["binds"])

  conn    = ActiveRecord::Base.connection
  adapter = conn.adapter_name.downcase
  full_sql = reconstruct_sql(sql, binds, conn, adapter)

  explain_sql, format = build_explain_statement(full_sql, adapter)

  rows = conn.exec_query(explain_sql, "EXPLAIN").to_a

  result = if format == "json"
    # PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
    raw = rows.first&.values&.first.to_s
    JSON.parse(raw) rescue raw
  else
    rows.map { |r| r.values.join("\t") }.join("\n")
  end

  { result: result, format: format, adapter: adapter }
end