Skip to content

Commit

Permalink
Added support for disabling explain and explain analyze
Browse files Browse the repository at this point in the history
  • Loading branch information
ankane committed Jan 5, 2023
1 parent e981247 commit 65aa2bc
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 12 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 3.1.0 (unreleased)

- Explain analyze is now opt-in - [more info](https://github.com/ankane/pghero/issues/438)
- Added support for disabling explain and explain analyze
- Added support for visualize without explain analyze
- Added `explain_v2` method

## 3.0.1 (2022-10-09)

- Fixed message when database user does not have permission to reset query stats
Expand Down
30 changes: 24 additions & 6 deletions app/controllers/pg_hero/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,22 +263,39 @@ def free_space_stats
end

def explain
unless @explain_enabled
render_text "Explain not enabled", status: :bad_request
return
end

@title = "Explain"
@query = params[:query]
@explain_analyze_enabled = PgHero.explain_mode == "analyze"

# TODO use get + token instead of post so users can share links
# need to prevent CSRF and DoS
if request.post? && @query
if request.post? && @query.present?
begin
prefix =
explain_options =
case params[:commit]
when "Analyze"
"ANALYZE "
{analyze: true}
when "Visualize"
"(ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) "
if @explain_analyze_enabled
{analyze: true, costs: true, verbose: true, buffers: true, format: "json"}
else
{costs: true, verbose: true, format: "json"}
end
else
""
{}
end
@explanation = @database.explain("#{prefix}#{@query}")

if explain_options[:analyze] && !@explain_analyze_enabled
render_text "Explain analyze not enabled", status: :bad_request
return
end

@explanation = @database.explain_v2(@query, **explain_options)
@suggested_index = @database.suggested_indexes(queries: [@query]).first if @database.suggested_indexes_enabled?
@visualize = params[:commit] == "Visualize"
rescue ActiveRecord::StatementInvalid => e
Expand Down Expand Up @@ -409,6 +426,7 @@ def set_query_stats_enabled
@query_stats_enabled = @database.query_stats_enabled?
@system_stats_enabled = @database.system_stats_enabled?
@replica = @database.replica?
@explain_enabled = PgHero.explain_enabled?
end

def set_suggested_indexes(min_average_time = 0, min_calls = 0)
Expand Down
4 changes: 3 additions & 1 deletion app/views/layouts/pg_hero/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
<% unless @database.replica? %>
<li class="<%= controller.action_name == "maintenance" ? "active" : "" %>"><%= link_to "Maintenance", maintenance_path %></li>
<% end %>
<li class="<%= controller.action_name == "explain" ? "active" : "" %>"><%= link_to "Explain", explain_path %></li>
<% if @explain_enabled %>
<li class="<%= controller.action_name == "explain" ? "active" : "" %>"><%= link_to "Explain", explain_path %></li>
<% end %>
<li class="<%= controller.action_name == "tune" ? "active" : "" %>"><%= link_to "Tune", tune_path %></li>
</ul>

Expand Down
4 changes: 3 additions & 1 deletion app/views/pg_hero/home/explain.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
<div class="field"><%= text_area_tag :query, @query, placeholder: "Enter a SQL query" %></div>
<p>
<%= submit_tag "Explain", class: "btn btn-info", style: "margin-right: 10px;" %>
<%= submit_tag "Analyze", class: "btn btn-danger", style: "margin-right: 10px;" %>
<% if @explain_analyze_enabled %>
<%= submit_tag "Analyze", class: "btn btn-danger", style: "margin-right: 10px;" %>
<% end %>
<%= submit_tag "Visualize", class: "btn btn-danger" %>
</p>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/pg_hero/home/show_query.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
highlightQueries()
</script>

<% if @explainable_query %>
<% if @explain_enabled && @explainable_query %>
<p>
<%= button_to "Explain", explain_path, params: {query: @explainable_query}, form: {target: "_blank"}, class: "btn btn-info" %>
</p>
Expand Down
3 changes: 3 additions & 0 deletions lib/generators/pghero/templates/config.yml.tt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ databases:
# Minimum connections for high connections warning
# total_connections_threshold: 500

# Explain functionality
# explain: true / false / analyze

# Statement timeout for explain
# explain_timeout_sec: 10

Expand Down
10 changes: 10 additions & 0 deletions lib/pghero.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ def stats_database_url
@stats_database_url ||= (file_config || {})["stats_database_url"] || ENV["PGHERO_STATS_DATABASE_URL"]
end

# private
def explain_enabled?
explain_mode.nil? || explain_mode == true || explain_mode == "analyze"
end

# private
def explain_mode
@config["explain"]
end

def visualize_url
@visualize_url ||= config["visualize_url"] || ENV["PGHERO_VISUALIZE_URL"] || "https://tatiyants.com/pev/#/plans/new"
end
Expand Down
34 changes: 34 additions & 0 deletions lib/pghero/methods/explain.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module PgHero
module Methods
module Explain
# TODO remove in 4.0
# note: this method is not affected by the explain option
def explain(sql)
sql = squish(sql)
explanation = nil
Expand All @@ -16,6 +18,23 @@ def explain(sql)
explanation
end

# TODO rename to explain in 4.0
# note: this method is not affected by the explain option
def explain_v2(sql, analyze: nil, verbose: nil, costs: nil, settings: nil, buffers: nil, wal: nil, timing: nil, summary: nil, format: "text")
options = []
add_explain_option(options, "ANALYZE", analyze)
add_explain_option(options, "VERBOSE", verbose)
add_explain_option(options, "SETTINGS", settings)
add_explain_option(options, "COSTS", costs)
add_explain_option(options, "BUFFERS", buffers)
add_explain_option(options, "WAL", wal)
add_explain_option(options, "TIMING", timing)
add_explain_option(options, "SUMMARY", summary)
options << "FORMAT #{explain_format(format)}"

explain("(#{options.join(", ")}) #{sql}")
end

private

def explain_safe?
Expand All @@ -24,6 +43,21 @@ def explain_safe?
rescue ActiveRecord::StatementInvalid
true
end

def add_explain_option(options, name, value)
unless value.nil?
options << "#{name}#{value ? "" : " FALSE"}"
end
end

# important! validate format to prevent injection
def explain_format(format)
if ["text", "xml", "json", "yaml"].include?(format)
format.upcase
else
raise ArgumentError, "Unknown format"
end
end
end
end
end
76 changes: 76 additions & 0 deletions test/controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,82 @@ def test_explain
assert_response :success
end

def test_explain_not_enabled
with_explain(false) do
get pg_hero.explain_path
end
assert_response :bad_request
assert_match "Explain not enabled", response.body
end

def test_explain_only
post pg_hero.explain_path, params: {query: "SELECT 1"}
assert_response :success
assert_match "Result (cost=0.00..0.01 rows=1 width=4)", response.body
refute_match "Planning Time:", response.body
refute_match "Execution Time:", response.body
end

def test_explain_only_not_enabled
with_explain(false) do
post pg_hero.explain_path, params: {query: "SELECT 1"}
end
assert_response :bad_request
assert_match "Explain not enabled", response.body
end

def test_explain_only_analyze
post pg_hero.explain_path, params: {query: "ANALYZE SELECT 1"}
assert_response :success
assert_match "syntax error", response.body
refute_match "Planning Time:", response.body
refute_match "Execution Time:", response.body
end

def test_explain_analyze
with_explain("analyze") do
post pg_hero.explain_path, params: {query: "SELECT 1", commit: "Analyze"}
end
assert_response :success
assert_match "(actual time=", response.body
assert_match "Planning Time:", response.body
assert_match "Execution Time:", response.body
end

def test_explain_analyze_timeout
with_explain("analyze") do
with_explain_timeout(0.01) do
post pg_hero.explain_path, params: {query: "SELECT pg_sleep(1)", commit: "Analyze"}
end
end
assert_response :success
assert_match "canceling statement due to statement timeout", response.body
end

def test_explain_analyze_not_enabled
post pg_hero.explain_path, params: {query: "SELECT 1", commit: "Analyze"}
assert_response :bad_request
assert_match "Explain analyze not enabled", response.body
end

def test_explain_visualize
post pg_hero.explain_path, params: {query: "SELECT 1", commit: "Visualize"}
assert_response :success
assert_match "https://tatiyants.com/pev/#/plans/new", response.body
assert_match "&quot;Node Type&quot;: &quot;Result&quot;", response.body
refute_match "Actual Total Time", response.body
end

def test_explain_visualize_analyze
with_explain("analyze") do
post pg_hero.explain_path, params: {query: "SELECT 1", commit: "Visualize"}
end
assert_response :success
assert_match "https://tatiyants.com/pev/#/plans/new", response.body
assert_match "&quot;Node Type&quot;: &quot;Result&quot;", response.body
assert_match "Actual Total Time", response.body
end

def test_tune
get pg_hero.tune_path
assert_response :success
Expand Down
46 changes: 43 additions & 3 deletions test/explain_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,49 @@ def test_explain_multiple_statements
assert_raises(ActiveRecord::StatementInvalid) { database.explain("ANALYZE DELETE FROM cities; DELETE FROM cities; COMMIT") }
end

def with_explain_timeout(value)
PgHero.stub(:explain_timeout_sec, value) do
yield
def test_explain_v2
database.explain_v2("SELECT 1")

# not affected by explain option
with_explain(false) do
database.explain_v2("SELECT 1")
end
end

def test_explain_v2_analyze
database.explain_v2("SELECT 1", analyze: true)

error = assert_raises(ActiveRecord::StatementInvalid) do
database.explain_v2("ANALYZE SELECT 1")
end
assert_match 'syntax error at or near "ANALYZE"', error.message

# not affected by explain option
with_explain(true) do
database.explain_v2("SELECT 1", analyze: true)
end
end

def test_explain_v2_format_text
assert_match "Result (cost=", database.explain_v2("SELECT 1", format: "text")
end

def test_explain_v2_format_json
assert_match '"Node Type": "Result"', database.explain_v2("SELECT 1", format: "json")
end

def test_explain_v2_format_xml
assert_match "<Node-Type>Result</Node-Type>", database.explain_v2("SELECT 1", format: "xml")
end

def test_explain_v2_format_yaml
assert_match 'Node Type: "Result"', database.explain_v2("SELECT 1", format: "yaml")
end

def test_explain_v2_format_bad
error = assert_raises(ArgumentError) do
database.explain_v2("SELECT 1", format: "bad")
end
assert_equal "Unknown format", error.message
end
end
12 changes: 12 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ class Minitest::Test
def database
@database ||= PgHero.databases[:primary]
end

def with_explain(value)
PgHero.stub(:explain_mode, value) do
yield
end
end

def with_explain_timeout(value)
PgHero.stub(:explain_timeout_sec, value) do
yield
end
end
end

logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDERR : nil)
Expand Down

0 comments on commit 65aa2bc

Please sign in to comment.