From c31b6eb1f19233294398fea26f9d68fa98be7a11 Mon Sep 17 00:00:00 2001 From: cdvx Date: Mon, 13 Jul 2026 12:06:42 +0300 Subject: [PATCH 1/3] Add adapter level read-only transaction support - Add adapter-level read_only_transaction callbacks for SQL adapters - Update mix ecto.query to use the transaction read_only opt instead of adapter-specific transaction SQL. --- lib/ecto/adapters/myxql/connection.ex | 6 ++++ lib/ecto/adapters/postgres/connection.ex | 12 +++++++ lib/ecto/adapters/sql.ex | 37 ++++++++++++++++++-- lib/ecto/adapters/sql/connection.ex | 9 +++++ lib/ecto/adapters/tds/connection.ex | 5 +++ lib/mix/tasks/ecto.query.ex | 44 +++++------------------- test/mix/tasks/ecto.query_test.exs | 44 ++++++++++-------------- 7 files changed, 94 insertions(+), 63 deletions(-) diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 833c6d35..204fcad8 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -27,6 +27,12 @@ if Code.ensure_loaded?(MyXQL) do MyXQL.query(conn, sql, params, opts) end + @impl true + def read_only_transaction(conn, opts, fun) do + {:ok, _} = query(conn, "SET TRANSACTION READ ONLY", [], log: nil) + DBConnection.transaction(conn, fun, opts) + end + @impl true def query_many(conn, sql, params, opts) do ensure_list_params!(params) diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index b6398531..f0de8d56 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -131,6 +131,18 @@ if Code.ensure_loaded?(Postgrex) do Postgrex.query(conn, sql, params, opts) end + @impl true + def read_only_transaction(conn, opts, fun) do + DBConnection.transaction( + conn, + fn transaction_conn -> + {:ok, _} = query(transaction_conn, "SET TRANSACTION READ ONLY", [], log: nil) + fun.(transaction_conn) + end, + opts + ) + end + @impl true def query_many(_conn, _sql, _params, _opts) do raise RuntimeError, "query_many is not supported in the PostgreSQL adapter" diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 4ae24c14..939c6760 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -278,7 +278,7 @@ defmodule Ecto.Adapters.SQL do @impl true def transaction(meta, opts, fun) do - Ecto.Adapters.SQL.transaction(meta, opts, fun) + Ecto.Adapters.SQL.transaction(meta, @conn, opts, fun) end @impl true @@ -1214,8 +1214,12 @@ defmodule Ecto.Adapters.SQL do ## Transactions @doc false - def transaction(adapter_meta, opts, callback) do - checkout_or_transaction(:transaction, adapter_meta, opts, callback) + def transaction(adapter_meta, connection, opts, callback) do + if opts[:read_only] do + read_only_transaction(adapter_meta, connection, opts, callback) + else + checkout_or_transaction(:transaction, adapter_meta, opts, callback) + end end @doc false @@ -1468,6 +1472,33 @@ defmodule Ecto.Adapters.SQL do ## Connection helpers + defp read_only_transaction(adapter_meta, connection, opts, callback) do + %{pid: pool, telemetry: telemetry, opts: default_opts, log_stacktrace_mfa: log_stacktrace_mfa} = + adapter_meta + + opts = with_log(telemetry, log_stacktrace_mfa, [], opts ++ default_opts) + + callback = fn transaction_conn -> + previous_conn = put_conn(pool, transaction_conn) + + try do + callback.() + after + reset_conn(pool, previous_conn) + end + end + + if function_exported?(connection, :read_only_transaction, 3) do + DBConnection.run( + get_conn_or_pool(pool, adapter_meta), + &connection.read_only_transaction(&1, opts, callback), + opts + ) + else + raise ArgumentError, "read-only transactions are not supported by #{inspect(connection)}" + end + end + defp checkout_or_transaction(fun, adapter_meta, opts, callback) do %{pid: pool, telemetry: telemetry, opts: default_opts, log_stacktrace_mfa: log_stacktrace_mfa} = adapter_meta diff --git a/lib/ecto/adapters/sql/connection.ex b/lib/ecto/adapters/sql/connection.ex index 19eee158..89a571f3 100644 --- a/lib/ecto/adapters/sql/connection.ex +++ b/lib/ecto/adapters/sql/connection.ex @@ -45,6 +45,15 @@ defmodule Ecto.Adapters.SQL.Connection do @callback query_many(connection, statement, params, options :: Keyword.t()) :: {:ok, term} | {:error, Exception.t()} + @doc """ + Runs the given function inside a read-only transaction. + """ + @callback read_only_transaction(connection, options :: Keyword.t(), (connection -> result)) :: + {:ok, result} | {:error, term()} + when result: var + + @optional_callbacks read_only_transaction: 3 + @doc """ Returns a stream that prepares and executes the given query with `DBConnection`. diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index be1cf229..b475a563 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -64,6 +64,11 @@ if Code.ensure_loaded?(Tds) do Tds.query(conn, sql, params, opts) end + @impl true + def read_only_transaction(_conn, _opts, _fun) do + raise ArgumentError, "read-only transactions are not supported by Ecto.Adapters.Tds" + end + @impl true def query_many(_conn, _sql, _params, _opts) do error!(nil, "query_many is not supported in the Tds adapter") diff --git a/lib/mix/tasks/ecto.query.ex b/lib/mix/tasks/ecto.query.ex index 1739a398..425eedaf 100644 --- a/lib/mix/tasks/ecto.query.ex +++ b/lib/mix/tasks/ecto.query.ex @@ -81,12 +81,15 @@ defmodule Mix.Tasks.Ecto.Query do if opts[:sql] do {:ok, format_sql(repo, query)} else - read_only_transaction(repo, fn -> - query - |> repo.all() - |> Enum.take(limit) - |> inspect_entries() - end) + repo.transaction( + fn -> + query + |> repo.all() + |> Enum.take(limit) + |> inspect_entries() + end, + read_only: true + ) end result @@ -145,35 +148,6 @@ defmodule Mix.Tasks.Ecto.Query do defp alias?({:alias, _, [_aliases, _opts]}), do: true defp alias?(_), do: false - defp read_only_transaction(repo, fun) do - do_read_only_transaction(repo.__adapter__(), repo, fun) - end - - defp do_read_only_transaction(Ecto.Adapters.Postgres, repo, fun) do - repo.transaction(fn -> - repo.query!("SET TRANSACTION READ ONLY", [], log: false) - fun.() - end) - end - - defp do_read_only_transaction(Ecto.Adapters.MyXQL, repo, fun) do - repo.checkout(fn -> - repo.query!("START TRANSACTION READ ONLY", [], log: false) - - try do - {:ok, fun.()} - after - repo.query!("ROLLBACK", [], log: false) - end - end) - end - - defp do_read_only_transaction(adapter, _repo, _fun) do - Mix.raise( - "ecto.query requires read-only transactions, which are not supported by #{inspect(adapter)}" - ) - end - defp format_sql(repo, query) do {sql, params} = repo.to_sql(:all, query) diff --git a/test/mix/tasks/ecto.query_test.exs b/test/mix/tasks/ecto.query_test.exs index aa49f729..e9577a71 100644 --- a/test/mix/tasks/ecto.query_test.exs +++ b/test/mix/tasks/ecto.query_test.exs @@ -52,9 +52,8 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["-r", to_string(__MODULE__.PostgresRepo), "from(p in Post)"]) - assert_received {:transaction, __MODULE__.PostgresRepo, _fun} - assert_received {:query!, "SET TRANSACTION READ ONLY", [], opts} - assert opts[:log] == false + assert_received {:transaction, __MODULE__.PostgresRepo, _fun, opts} + assert opts[:read_only] assert_received {:all, %Ecto.Query{}} assert_received {:mix_shell, :info, [output]} @@ -78,7 +77,8 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["from(p in Post)"]) - assert_received {:query!, "SET TRANSACTION READ ONLY", [], _opts} + assert_received {:transaction, __MODULE__.PostgresRepo, _fun, opts} + assert opts[:read_only] end) end @@ -196,7 +196,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do assert_received {:to_sql, :all, %Ecto.Query{}, []} refute_received {:all, _query} - refute_received {:query!, "SET TRANSACTION READ ONLY", [], _opts} + refute_received {:transaction, __MODULE__.PostgresRepo, _fun, _opts} assert_received {:mix_shell, :info, [output]} assert output =~ ~s[SQL:\nSELECT p0."id" FROM "posts" AS p0 WHERE (p0."id" = $1)] @@ -204,17 +204,14 @@ defmodule Mix.Tasks.Ecto.QueryTest do end) end - test "starts MySQL read-only transactions explicitly" do + test "runs MySQL queries in a read-only transaction" do Process.put(:test_repo_all_results, [[1, "first", "hunter2"]]) run(["-r", to_string(__MODULE__.MyXQLRepo), inspect(Post)]) - assert_received {:checkout, __MODULE__.MyXQLRepo} - assert_received {:myxql_query!, "START TRANSACTION READ ONLY", [], opts} - assert opts[:log] == false + assert_received {:myxql_transaction, __MODULE__.MyXQLRepo, _fun, opts} + assert opts[:read_only] assert_received {:myxql_all, %Ecto.Query{}} - assert_received {:myxql_query!, "ROLLBACK", [], rollback_opts} - assert rollback_opts[:log] == false assert_received {:mix_shell, :info, [output]} assert output =~ ~s(title: "first") end @@ -224,9 +221,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["-r", to_string(__MODULE__.MyXQLRepo), "--sql", inspect(Post)]) - refute_received {:checkout, __MODULE__.MyXQLRepo} - refute_received {:myxql_query!, "START TRANSACTION READ ONLY", [], _opts} - refute_received {:myxql_query!, "ROLLBACK", [], _opts} + refute_received {:myxql_transaction, __MODULE__.MyXQLRepo, _fun, _opts} assert_received {:myxql_to_sql, :all, %Ecto.Query{}, []} assert_received {:mix_shell, :info, [output]} assert output =~ ~s[SQL:\nSELECT p0.`id` FROM `posts` AS p0] @@ -278,20 +273,24 @@ defmodule Mix.Tasks.Ecto.QueryTest do end test "raises when the adapter does not support read-only transactions" do - assert_raise Mix.Error, ~r/read-only transactions.*Ecto.Adapters.Tds/s, fn -> + assert_raise ArgumentError, ~r/read-only transactions.*Ecto.Adapters.Tds/s, fn -> run(["-r", to_string(__MODULE__.NoReadOnlyRepo), inspect(Post)]) end end defmodule NoReadOnlyRepo do def __adapter__, do: Ecto.Adapters.Tds + + def transaction(_fun, _opts) do + raise ArgumentError, "read-only transactions are not supported by Ecto.Adapters.Tds" + end end defmodule PostgresRepo do def __adapter__, do: Ecto.Adapters.Postgres - def transaction(fun, _opts \\ []) do - send(self(), {:transaction, __MODULE__, fun}) + def transaction(fun, opts \\ []) do + send(self(), {:transaction, __MODULE__, fun, opts}) {:ok, fun.()} end @@ -326,14 +325,9 @@ defmodule Mix.Tasks.Ecto.QueryTest do defmodule MyXQLRepo do def __adapter__, do: Ecto.Adapters.MyXQL - def checkout(fun, _opts \\ []) do - send(test_process(), {:checkout, __MODULE__}) - fun.() - end - - def query!(sql, params \\ [], opts \\ []) do - send(test_process(), {:myxql_query!, sql, params, opts}) - %{rows: [], num_rows: 0} + def transaction(fun, opts \\ []) do + send(test_process(), {:myxql_transaction, __MODULE__, fun, opts}) + {:ok, fun.()} end def all(query) do From f57d7885317680e58a7c95979af8c72963693906 Mon Sep 17 00:00:00 2001 From: cdvx Date: Mon, 13 Jul 2026 20:18:43 +0300 Subject: [PATCH 2/3] Add adapter level read-only transaction support - Log SET TRANSACTION READ ONLY through the transaction logger - Remove the TDS read_only_transaction callback so Ecto.Adapters.SQL raises the unsupported adapter error --- lib/ecto/adapters/myxql/connection.ex | 2 +- lib/ecto/adapters/postgres/connection.ex | 2 +- lib/ecto/adapters/tds/connection.ex | 5 ----- test/mix/tasks/ecto.query_test.exs | 4 ++-- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 204fcad8..97402138 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -29,7 +29,7 @@ if Code.ensure_loaded?(MyXQL) do @impl true def read_only_transaction(conn, opts, fun) do - {:ok, _} = query(conn, "SET TRANSACTION READ ONLY", [], log: nil) + {:ok, _} = query(conn, "SET TRANSACTION READ ONLY", [], log: opts[:log]) DBConnection.transaction(conn, fun, opts) end diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index f0de8d56..769bcfbb 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -136,7 +136,7 @@ if Code.ensure_loaded?(Postgrex) do DBConnection.transaction( conn, fn transaction_conn -> - {:ok, _} = query(transaction_conn, "SET TRANSACTION READ ONLY", [], log: nil) + {:ok, _} = query(transaction_conn, "SET TRANSACTION READ ONLY", [], log: opts[:log]) fun.(transaction_conn) end, opts diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index b475a563..be1cf229 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -64,11 +64,6 @@ if Code.ensure_loaded?(Tds) do Tds.query(conn, sql, params, opts) end - @impl true - def read_only_transaction(_conn, _opts, _fun) do - raise ArgumentError, "read-only transactions are not supported by Ecto.Adapters.Tds" - end - @impl true def query_many(_conn, _sql, _params, _opts) do error!(nil, "query_many is not supported in the Tds adapter") diff --git a/test/mix/tasks/ecto.query_test.exs b/test/mix/tasks/ecto.query_test.exs index e9577a71..4c369abc 100644 --- a/test/mix/tasks/ecto.query_test.exs +++ b/test/mix/tasks/ecto.query_test.exs @@ -273,7 +273,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do end test "raises when the adapter does not support read-only transactions" do - assert_raise ArgumentError, ~r/read-only transactions.*Ecto.Adapters.Tds/s, fn -> + assert_raise ArgumentError, ~r/read-only transactions are not supported/, fn -> run(["-r", to_string(__MODULE__.NoReadOnlyRepo), inspect(Post)]) end end @@ -282,7 +282,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do def __adapter__, do: Ecto.Adapters.Tds def transaction(_fun, _opts) do - raise ArgumentError, "read-only transactions are not supported by Ecto.Adapters.Tds" + raise ArgumentError, "read-only transactions are not supported" end end From 8f4ec1bfdda478fdcddd41b3a09ee71c3ddf5273 Mon Sep 17 00:00:00 2001 From: cdvx Date: Wed, 15 Jul 2026 11:22:20 +0300 Subject: [PATCH 3/3] Keep read-only transactions adapter-only - Move read-only query execution out of the Repo.transaction API - Call the SQL adapter hook directly from mix ecto.query. --- lib/ecto/adapters/sql.ex | 18 +++++++------ lib/mix/tasks/ecto.query.ex | 19 ++++++------- test/mix/tasks/ecto.query_test.exs | 43 +++++++++++++++++++++--------- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 939c6760..4dac125c 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -278,7 +278,12 @@ defmodule Ecto.Adapters.SQL do @impl true def transaction(meta, opts, fun) do - Ecto.Adapters.SQL.transaction(meta, @conn, opts, fun) + Ecto.Adapters.SQL.transaction(meta, opts, fun) + end + + @doc false + def read_only_transaction(repo, opts, fun) do + Ecto.Adapters.SQL.read_only_transaction(Ecto.Adapter.lookup_meta(repo), @conn, opts, fun) end @impl true @@ -1214,12 +1219,8 @@ defmodule Ecto.Adapters.SQL do ## Transactions @doc false - def transaction(adapter_meta, connection, opts, callback) do - if opts[:read_only] do - read_only_transaction(adapter_meta, connection, opts, callback) - else - checkout_or_transaction(:transaction, adapter_meta, opts, callback) - end + def transaction(adapter_meta, opts, callback) do + checkout_or_transaction(:transaction, adapter_meta, opts, callback) end @doc false @@ -1472,7 +1473,8 @@ defmodule Ecto.Adapters.SQL do ## Connection helpers - defp read_only_transaction(adapter_meta, connection, opts, callback) do + @doc false + def read_only_transaction(adapter_meta, connection, opts, callback) do %{pid: pool, telemetry: telemetry, opts: default_opts, log_stacktrace_mfa: log_stacktrace_mfa} = adapter_meta diff --git a/lib/mix/tasks/ecto.query.ex b/lib/mix/tasks/ecto.query.ex index 425eedaf..b294997b 100644 --- a/lib/mix/tasks/ecto.query.ex +++ b/lib/mix/tasks/ecto.query.ex @@ -81,15 +81,12 @@ defmodule Mix.Tasks.Ecto.Query do if opts[:sql] do {:ok, format_sql(repo, query)} else - repo.transaction( - fn -> - query - |> repo.all() - |> Enum.take(limit) - |> inspect_entries() - end, - read_only: true - ) + read_only_transaction(repo, fn -> + query + |> repo.all() + |> Enum.take(limit) + |> inspect_entries() + end) end result @@ -160,6 +157,10 @@ defmodule Mix.Tasks.Ecto.Query do """ end + defp read_only_transaction(repo, fun) do + repo.__adapter__().read_only_transaction(repo.get_dynamic_repo(), [], fun) + end + defp inspect_entries(entries) do previous_fun = Inspect.Opts.default_inspect_fun() diff --git a/test/mix/tasks/ecto.query_test.exs b/test/mix/tasks/ecto.query_test.exs index 4c369abc..7ca1e671 100644 --- a/test/mix/tasks/ecto.query_test.exs +++ b/test/mix/tasks/ecto.query_test.exs @@ -52,8 +52,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["-r", to_string(__MODULE__.PostgresRepo), "from(p in Post)"]) - assert_received {:transaction, __MODULE__.PostgresRepo, _fun, opts} - assert opts[:read_only] + assert_received {:read_only_transaction, __MODULE__.PostgresRepo, _fun, []} assert_received {:all, %Ecto.Query{}} assert_received {:mix_shell, :info, [output]} @@ -77,8 +76,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["from(p in Post)"]) - assert_received {:transaction, __MODULE__.PostgresRepo, _fun, opts} - assert opts[:read_only] + assert_received {:read_only_transaction, __MODULE__.PostgresRepo, _fun, []} end) end @@ -196,7 +194,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do assert_received {:to_sql, :all, %Ecto.Query{}, []} refute_received {:all, _query} - refute_received {:transaction, __MODULE__.PostgresRepo, _fun, _opts} + refute_received {:read_only_transaction, __MODULE__.PostgresRepo, _fun, _opts} assert_received {:mix_shell, :info, [output]} assert output =~ ~s[SQL:\nSELECT p0."id" FROM "posts" AS p0 WHERE (p0."id" = $1)] @@ -209,8 +207,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["-r", to_string(__MODULE__.MyXQLRepo), inspect(Post)]) - assert_received {:myxql_transaction, __MODULE__.MyXQLRepo, _fun, opts} - assert opts[:read_only] + assert_received {:myxql_read_only_transaction, __MODULE__.MyXQLRepo, _fun, []} assert_received {:myxql_all, %Ecto.Query{}} assert_received {:mix_shell, :info, [output]} assert output =~ ~s(title: "first") @@ -221,7 +218,7 @@ defmodule Mix.Tasks.Ecto.QueryTest do run(["-r", to_string(__MODULE__.MyXQLRepo), "--sql", inspect(Post)]) - refute_received {:myxql_transaction, __MODULE__.MyXQLRepo, _fun, _opts} + refute_received {:myxql_read_only_transaction, __MODULE__.MyXQLRepo, _fun, _opts} assert_received {:myxql_to_sql, :all, %Ecto.Query{}, []} assert_received {:mix_shell, :info, [output]} assert output =~ ~s[SQL:\nSELECT p0.`id` FROM `posts` AS p0] @@ -279,15 +276,21 @@ defmodule Mix.Tasks.Ecto.QueryTest do end defmodule NoReadOnlyRepo do - def __adapter__, do: Ecto.Adapters.Tds + def __adapter__, do: Mix.Tasks.Ecto.QueryTest.NoReadOnlyAdapter - def transaction(_fun, _opts) do + def get_dynamic_repo, do: __MODULE__ + end + + defmodule NoReadOnlyAdapter do + def read_only_transaction(_repo, _opts, _fun) do raise ArgumentError, "read-only transactions are not supported" end end defmodule PostgresRepo do - def __adapter__, do: Ecto.Adapters.Postgres + def __adapter__, do: Mix.Tasks.Ecto.QueryTest.PostgresAdapter + + def get_dynamic_repo, do: __MODULE__ def transaction(fun, opts \\ []) do send(self(), {:transaction, __MODULE__, fun, opts}) @@ -322,8 +325,17 @@ defmodule Mix.Tasks.Ecto.QueryTest do end end + defmodule PostgresAdapter do + def read_only_transaction(repo, opts, fun) do + send(self(), {:read_only_transaction, repo, fun, opts}) + {:ok, fun.()} + end + end + defmodule MyXQLRepo do - def __adapter__, do: Ecto.Adapters.MyXQL + def __adapter__, do: Mix.Tasks.Ecto.QueryTest.MyXQLAdapter + + def get_dynamic_repo, do: __MODULE__ def transaction(fun, opts \\ []) do send(test_process(), {:myxql_transaction, __MODULE__, fun, opts}) @@ -350,6 +362,13 @@ defmodule Mix.Tasks.Ecto.QueryTest do end end + defmodule MyXQLAdapter do + def read_only_transaction(repo, opts, fun) do + send(self(), {:myxql_read_only_transaction, repo, fun, opts}) + {:ok, fun.()} + end + end + @tmp_path Path.expand("../../../tmp", __DIR__) defp in_tmp(path, fun) do