Skip to content

Commit e40eab3

Browse files
Add :plan explain option for Postgres (#604)
1 parent 70ae76c commit e40eab3

5 files changed

Lines changed: 152 additions & 18 deletions

File tree

Earthfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ integration-test-mysql:
103103

104104

105105
integration-test-mssql:
106-
ARG TARGETARCH
106+
ARG TARGETARCH
107107
FROM +setup-base
108108

109109
RUN apk add --no-cache curl gnupg --virtual .build-dependencies -- && \

integration_test/pg/explain_test.exs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,65 @@ defmodule Ecto.Integration.ExplainTest do
2727
end)
2828
end
2929

30+
@tag :plan_cache_mode
31+
test "explain with fallback generic plan" do
32+
# when using fallback generic plan , placeholders are used instead of values. i.e. $1 instead of 1
33+
query = from p in Post, where: p.visits == ^1 and p.title == ^"title"
34+
35+
explain =
36+
TestRepo.explain(:all, query, plan: :fallback_generic, verbose: true, timeout: 20000)
37+
38+
assert explain =~ "p0.visits = $1"
39+
assert explain =~ "(p0.title)::text = $2"
40+
end
41+
42+
test "explain with fallback generic plan cannot use analyze" do
43+
msg = ~r/analyze cannot be used with a `:fallback_generic` explain plan/
44+
45+
assert_raise ArgumentError, msg, fn ->
46+
TestRepo.explain(:all, Post, plan: :fallback_generic, analyze: true)
47+
end
48+
end
49+
50+
test "explain with custom plan" do
51+
# when using custom plan, values are used instead of placeholders. i.e. 1 instead of $1
52+
query = from p in Post, where: p.visits == ^1 and p.title == ^"title"
53+
54+
explain =
55+
TestRepo.explain(:all, query, plan: :custom, analyze: true, verbose: true, timeout: 20000)
56+
57+
refute explain =~ "$1"
58+
refute explain =~ "$2"
59+
assert explain =~ "p0.visits = 1"
60+
assert explain =~ "(p0.title)::text = 'title'"
61+
end
62+
63+
@tag :explain_generic
64+
test "explain with generic plan" do
65+
# when using generic plan, placeholders are used instead of values. i.e. $1 instead of 1
66+
query = from p in Post, where: p.visits == ^1 and p.title == ^"title"
67+
68+
explain =
69+
TestRepo.explain(:all, query, plan: :generic, analyze: true, verbose: true, timeout: 20000)
70+
71+
assert explain =~ "p0.visits = $1"
72+
assert explain =~ "(p0.title)::text = $2"
73+
end
74+
3075
test "explain MAP format" do
31-
[explain] = TestRepo.explain(:all, Post, analyze: true, verbose: true, timeout: 20000, format: :map)
32-
keys = explain["Plan"] |> Map.keys
76+
[explain] =
77+
TestRepo.explain(:all, Post, analyze: true, verbose: true, timeout: 20000, format: :map)
78+
79+
keys = explain["Plan"] |> Map.keys()
3380
assert Enum.member?(keys, "Actual Loops")
3481
assert Enum.member?(keys, "Actual Rows")
3582
assert Enum.member?(keys, "Actual Startup Time")
3683
end
3784

3885
test "explain YAML format" do
39-
explain = TestRepo.explain(:all, Post, analyze: true, verbose: true, timeout: 20000, format: :yaml)
86+
explain =
87+
TestRepo.explain(:all, Post, analyze: true, verbose: true, timeout: 20000, format: :yaml)
88+
4089
assert explain =~ ~r/Plan:/
4190
assert explain =~ ~r/Node Type:/
4291
assert explain =~ ~r/Relation Name:/

integration_test/pg/test_helper.exs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,24 @@ version =
100100
excludes = [:selected_as_with_having, :selected_as_with_order_by_expression]
101101
excludes_above_9_5 = [:without_conflict_target]
102102
excludes_below_9_6 = [:add_column_if_not_exists, :no_error_on_conditional_column_migration]
103+
excludes_below_12_0 = [:plan_cache_mode]
103104
excludes_below_15_0 = [:on_delete_nilify_column_list]
105+
excludes_below_16_0 = [:explain_generic]
104106

105107
exclude_list = excludes ++ excludes_above_9_5
106108

107109
cond do
108110
Version.match?(version, "< 9.6.0") ->
109-
ExUnit.configure(exclude: exclude_list ++ excludes_below_9_6 ++ excludes_below_15_0)
111+
ExUnit.configure(exclude: exclude_list ++ excludes_below_9_6 ++ excludes_below_12_0 ++ excludes_below_15_0 ++ excludes_below_16_0)
112+
113+
Version.match?(version, "< 12.0.0") ->
114+
ExUnit.configure(exclude: exclude_list ++ excludes_below_12_0 ++ excludes_below_15_0 ++ excludes_below_16_0)
110115

111116
Version.match?(version, "< 15.0.0") ->
112-
ExUnit.configure(exclude: exclude_list ++ excludes_below_15_0)
117+
ExUnit.configure(exclude: exclude_list ++ excludes_below_15_0 ++ excludes_below_16_0)
118+
119+
Version.match?(version, "< 16.0.0") ->
120+
ExUnit.configure(exclude: exclude_list ++ excludes_below_16_0)
113121

114122
true ->
115123
ExUnit.configure(exclude: exclude_list)

lib/ecto/adapters/postgres/connection.ex

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ if Code.ensure_loaded?(Postgrex) do
44

55
@default_port 5432
66
@behaviour Ecto.Adapters.SQL.Connection
7+
@explain_prepared_statement_name "ecto_explain_statement"
78

89
## Module and Options
910

@@ -357,11 +358,33 @@ if Code.ensure_loaded?(Postgrex) do
357358
@impl true
358359
def explain_query(conn, query, params, opts) do
359360
{explain_opts, opts} =
360-
Keyword.split(opts, ~w[analyze verbose costs settings buffers timing summary format]a)
361+
Keyword.split(
362+
opts,
363+
~w[analyze verbose costs settings buffers timing summary format plan]a
364+
)
365+
366+
fallback_generic? = explain_opts[:plan] == :fallback_generic
361367

362-
map_format? = {:format, :map} in explain_opts
368+
result =
369+
cond do
370+
fallback_generic? and explain_opts[:analyze] ->
371+
raise ArgumentError,
372+
"analyze cannot be used with a `:fallback_generic` explain plan " <>
373+
"as the actual parameter values are ignored under this plan type." <>
374+
"You may either change the plan type to `:custom` or remove the `:analyze` option."
375+
376+
fallback_generic? ->
377+
explain_opts = Keyword.delete(explain_opts, :plan)
378+
explain_queries = build_fallback_generic_queries(query, length(params), explain_opts)
379+
fallback_generic_query(conn, explain_queries, opts)
380+
381+
true ->
382+
query(conn, build_explain_query(query, explain_opts), params, opts)
383+
end
363384

364-
case query(conn, build_explain_query(query, explain_opts), params, opts) do
385+
map_format? = explain_opts[:format] == :map
386+
387+
case result do
365388
{:ok, %Postgrex.Result{rows: rows}} when map_format? ->
366389
{:ok, List.flatten(rows)}
367390

@@ -373,12 +396,45 @@ if Code.ensure_loaded?(Postgrex) do
373396
end
374397
end
375398

376-
def build_explain_query(query, []) do
377-
["EXPLAIN ", query]
378-
|> IO.iodata_to_binary()
399+
def build_fallback_generic_queries(query, num_params, opts) do
400+
prepare =
401+
[
402+
"PREPARE ",
403+
@explain_prepared_statement_name,
404+
"(",
405+
Enum.map_intersperse(1..num_params, ", ", fn _ -> "unknown" end),
406+
") AS ",
407+
query
408+
]
409+
|> IO.iodata_to_binary()
410+
411+
set = "SET LOCAL plan_cache_mode = force_generic_plan"
412+
413+
execute =
414+
[
415+
"EXPLAIN ",
416+
build_explain_opts(opts),
417+
"EXECUTE ",
418+
@explain_prepared_statement_name,
419+
"(",
420+
Enum.map_intersperse(1..num_params, ", ", fn _ -> "NULL" end),
421+
")"
422+
]
423+
|> IO.iodata_to_binary()
424+
425+
deallocate = "DEALLOCATE #{@explain_prepared_statement_name}"
426+
427+
{prepare, set, execute, deallocate}
379428
end
380429

381430
def build_explain_query(query, opts) do
431+
["EXPLAIN ", build_explain_opts(opts), query]
432+
|> IO.iodata_to_binary()
433+
end
434+
435+
defp build_explain_opts([]), do: []
436+
437+
defp build_explain_opts(opts) do
382438
{analyze, opts} = Keyword.pop(opts, :analyze)
383439
{verbose, opts} = Keyword.pop(opts, :verbose)
384440

@@ -388,10 +444,8 @@ if Code.ensure_loaded?(Postgrex) do
388444
case opts do
389445
[] ->
390446
[
391-
"EXPLAIN ",
392447
if_do(quote_boolean(analyze) == "TRUE", "ANALYZE "),
393-
if_do(quote_boolean(verbose) == "TRUE", "VERBOSE "),
394-
query
448+
if_do(quote_boolean(verbose) == "TRUE", "VERBOSE ")
395449
]
396450

397451
opts ->
@@ -404,15 +458,31 @@ if Code.ensure_loaded?(Postgrex) do
404458
{:format, value}, acc ->
405459
[String.upcase("#{format_to_sql(value)}") | acc]
406460

461+
{:plan, :generic}, acc ->
462+
["GENERIC" | acc]
463+
464+
{:plan, _}, acc ->
465+
acc
466+
407467
{opt, value}, acc ->
408468
[String.upcase("#{opt} #{quote_boolean(value)}") | acc]
409469
end)
410470
|> Enum.reverse()
411471
|> Enum.join(", ")
412472

413-
["EXPLAIN ( ", opts, " ) ", query]
473+
["( ", opts, " ) "]
474+
end
475+
end
476+
477+
defp fallback_generic_query(conn, queries, opts) do
478+
{prepare, set, execute, deallocate} = queries
479+
480+
with {:ok, _} <- query(conn, prepare, [], opts),
481+
{:ok, _} <- query(conn, set, [], opts),
482+
{:ok, result} <- query(conn, execute, [], opts),
483+
{:ok, _} <- query(conn, deallocate, [], opts) do
484+
{:ok, result}
414485
end
415-
|> IO.iodata_to_binary()
416486
end
417487

418488
## Query generation

lib/ecto/adapters/sql.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ defmodule Ecto.Adapters.SQL do
387387
388388
Adapter | Supported opts
389389
---------------- | --------------
390-
Postgrex | `analyze`, `verbose`, `costs`, `settings`, `buffers`, `timing`, `summary`, `format`
390+
Postgrex | `analyze`, `verbose`, `costs`, `settings`, `buffers`, `timing`, `summary`, `format`, `plan`
391391
MyXQL | `format`
392392
393393
All options except `format` are boolean valued and default to `false`.
@@ -400,6 +400,13 @@ defmodule Ecto.Adapters.SQL do
400400
* Postgrex: `:map`, `:yaml` and `:text`
401401
* MyXQL: `:map` and `:text`
402402
403+
The `:plan` option in Postgres can take the values `:custom`, `:generic` or `:fallback_generic`.
404+
When `:custom` is specified, the explain plan generated by Postgres will consider the specific values
405+
of the query parameters that are supplied. When using `:generic` or `:fallback_generic`, the specific
406+
values of the query parameters will be ignored. The difference between the two is that `:generic`
407+
utilizes Postgres's built-in functionality (available since Postgres 16) and `:fallback_generic` is
408+
a special implementation for earlier Postgres versions. Defaults to `:custom`.
409+
403410
Any other value passed to `opts` will be forwarded to the underlying adapter query function, including
404411
shared Repo options such as `:timeout`. Non built-in adapters may have specific behaviour and you should
405412
consult their documentation for more details.

0 commit comments

Comments
 (0)