diff --git a/README.md b/README.md index 1b96ddb..94eb34a 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ Once the test finishes, the managed transaction is being rollbacked to restore t ## Installation The package can be installed by adding `ecto_replay_sandbox` to your list of dependencies in `mix.exs`. -Make sure to also add [Postgrex CockroachDB variant](https://hexdocs.pm/postgrex_cdb/readme.html). +Make sure to also add [CockroachDB adaptor](https://hexdocs.pm/ecto_cockroachdb/readme.html). ```elixir def deps do [ - {:postgrex, "~> 0.13", hex: :postgrex_cdb, override: true}, - {:ecto_replay_sandbox, "~> 1.0", only: :test}, + {:ecto_cockroachdb, "~> 1.0"}, + {:ecto_replay_sandbox, "~> 2.0", only: :test}, ] end ``` diff --git a/lib/ecto_replay_sandbox.ex b/lib/sandbox.ex similarity index 71% rename from lib/ecto_replay_sandbox.ex rename to lib/sandbox.ex index 5dd88b4..db140e8 100644 --- a/lib/ecto_replay_sandbox.ex +++ b/lib/sandbox.ex @@ -295,11 +295,29 @@ defmodule EctoReplaySandbox do you can always disable the test triggering the deadlock from running asynchronously by setting "async: false". """ - + defmodule Connection do - @begin_result %Postgrex.Result{columns: nil, command: :begin, connection_id: nil, num_rows: nil, rows: nil} - @commit_result %Postgrex.Result{columns: nil, command: :commit, connection_id: nil, num_rows: nil, rows: nil} - @rollback_result %Postgrex.Result{columns: nil, command: :rollback, connection_id: nil, num_rows: nil, rows: nil} + @begin_result %Postgrex.Result{ + columns: nil, + command: :begin, + connection_id: nil, + num_rows: nil, + rows: nil + } + @commit_result %Postgrex.Result{ + columns: nil, + command: :commit, + connection_id: nil, + num_rows: nil, + rows: nil + } + @rollback_result %Postgrex.Result{ + columns: nil, + command: :rollback, + connection_id: nil, + num_rows: nil, + rows: nil + } @moduledoc false if Code.ensure_loaded?(DBConnection) do @@ -310,7 +328,7 @@ defmodule EctoReplaySandbox do raise "should never be invoked" end - def disconnect(err, {conn_mod, state, _in_transaction?, _fixture_state}) do + def disconnect(err, {conn_mod, state, _in_transaction?}) do conn_mod.disconnect(err, state) end @@ -321,187 +339,162 @@ defmodule EctoReplaySandbox do def handle_begin(_opts, {conn_mod, state, false, {sandbox_log, _tx_log}}) do {:ok, @begin_result, {conn_mod, state, true, {sandbox_log, []}}} end + def handle_commit(_opts, {conn_mod, state, true, {sandbox_log, tx_log}}) do - {state, sandbox_log} = case sandbox_log do - [:error_detected | tail] -> - restart_result = restart_sandbox_tx(conn_mod, state) - {elem(restart_result, 2),[:replay_needed | tail]} - _ -> - {state, sandbox_log ++ tx_log} - end - + {state, sandbox_log} = + case sandbox_log do + [:error_detected | tail] -> + restart_result = restart_sandbox_tx(conn_mod, state) + {elem(restart_result, 2), [:replay_needed | tail]} + + _ -> + {state, sandbox_log ++ tx_log} + end + {:ok, @commit_result, {conn_mod, state, false, {sandbox_log, []}}} end + def handle_rollback(opts, {conn_mod, state, true, {sandbox_log, _}}) do - sandbox_log = case sandbox_log do - [:error_detected | tail] -> tail - _ -> sandbox_log - end + sandbox_log = + case sandbox_log do + [:error_detected | tail] -> tail + _ -> sandbox_log + end + case restart_sandbox_tx(conn_mod, state, opts) do {:ok, _, conn_state} -> - {:ok, @rollback_result, {conn_mod, conn_state, false, {[:replay_needed | sandbox_log], []}}} - error -> + {:ok, @rollback_result, + {conn_mod, conn_state, false, {[:replay_needed | sandbox_log], []}}} + + error -> pos = :erlang.tuple_size(error) - :erlang.setelement(pos, error, {conn_mod, :erlang.element(pos, error), false, {sandbox_log, []}}) + + :erlang.setelement( + pos, + error, + {conn_mod, :erlang.element(pos, error), false, {sandbox_log, []}} + ) end end + def handle_status(opts, state), + do: proxy(:handle_status, state, [opts]) + def handle_prepare(query, opts, state), do: proxy(:handle_prepare, state, [query, opts]) + def handle_execute(query, params, opts, state), do: proxy(:handle_execute, state, [query, params, opts]) + def handle_close(query, opts, state), do: proxy(:handle_close, state, [query, opts]) + def handle_declare(query, params, opts, state), do: proxy(:handle_declare, state, [query, params, opts]) - def handle_first(query, cursor, opts, state), - do: proxy(:handle_first, state, [query, cursor, opts]) - def handle_next(query, cursor, opts, state), - do: proxy(:handle_next, state, [query, cursor, opts]) + + def handle_fetch(query, cursor, opts, state), + do: proxy(:handle_fetch, state, [query, cursor, opts]) + def handle_deallocate(query, cursor, opts, state), do: proxy(:handle_deallocate, state, [query, cursor, opts]) - def handle_info(msg, state), - do: proxy(:handle_info, state, [msg]) defp proxy(fun, {conn_mod, state, in_transaction?, {sandbox_log, _tx_log} = log_state}, args) do # Handle replay - {state, log_state} = case sandbox_log do - [:replay_needed | tail] -> - state = tail - |> Enum.reduce(state, fn {replay_fun, replay_args}, state -> - {_status, _, state} = apply(conn_mod, replay_fun, replay_args ++ [state]) + {state, log_state} = + case sandbox_log do + [:replay_needed | tail] -> + state = + tail + |> Enum.reduce(state, fn {replay_fun, replay_args}, state -> + {:ok, _, state} = + apply(conn_mod, replay_fun, replay_args ++ [state]) |> normalize_result + state end) - {state, {tail, []}} - _ -> - {state, log_state} - end + + {state, {tail, []}} + + _ -> + {state, log_state} + end # Execute command - {status, result, state} = apply(conn_mod, fun, args ++ [state]) + {status, result, state} = apply(conn_mod, fun, args ++ [state]) |> normalize_result # Handle error - {state, log_state} = case status do - :ok -> - {state, log_command(fun, args, in_transaction?, log_state)} - :error -> - if(in_transaction?) do - log_state = case sandbox_log do - [:error_detected | _tail] -> {elem(log_state, 0), []} - _ -> {[:error_detected | elem(log_state, 0)], []} + {state, log_state} = + case status do + ok_val when ok_val in [:ok, :cont, :halt] -> + {state, log_command(fun, args, in_transaction?, log_state)} + + error_val when error_val in [:error, :disconnect] -> + if(in_transaction?) do + log_state = + case sandbox_log do + [:error_detected | _tail] -> {elem(log_state, 0), []} + _ -> {[:error_detected | elem(log_state, 0)], []} + end + + {state, log_state} + else + {_status, _result, state} = restart_sandbox_tx(conn_mod, state) + {state, {[:replay_needed | elem(log_state, 0)], []}} end - {state, log_state} - else - restart_result = restart_sandbox_tx(conn_mod, state) - {elem(restart_result, 2),{[:replay_needed | elem(log_state, 0)], []}} - end - end + end + + put_elem(result, tuple_size(result) - 1, {conn_mod, state, in_transaction?, log_state}) + end - {status, result, {conn_mod, state, in_transaction?, log_state}} + defp normalize_result(result) do + status = elem(result, 0) + state = elem(result, tuple_size(result) - 1) + {status, result, state} end defp log_command(fun, args, in_transactions?, {sandbox_log, tx_log}) do case fun do - command when command in [:handle_execute, :handle_close, :handle_declare, :handle_first, :handle_next, :handle_deallocate] -> + command + when command in [ + :handle_execute, + :handle_close, + :handle_declare, + :handle_fetch, + :handle_deallocate + ] -> if in_transactions? do {sandbox_log, tx_log ++ [{fun, args}]} else {sandbox_log ++ [{fun, args}], []} end + _ -> {sandbox_log, tx_log} end end defp restart_sandbox_tx(conn_mod, conn_state, opts \\ []) do - with {:ok, _, conn_state} <- conn_mod.handle_rollback([mode: :transaction] ++ opts, conn_state), + with {:ok, _, conn_state} <- + conn_mod.handle_rollback([mode: :transaction] ++ opts, conn_state), {:ok, _, _} = begin_result <- conn_mod.handle_begin([mode: :transaction], conn_state) do begin_result end end - - end - - defmodule Pool do - @moduledoc false - if Code.ensure_loaded?(DBConnection) do - @behaviour DBConnection.Pool - end - - def ensure_all_started(_opts, _type) do - raise "should never be invoked" - end - - def start_link(_module, _opts) do - raise "should never be invoked" - end - - def child_spec(_module, _opts, _child_opts) do - raise "should never be invoked" - end - - def checkout(pool, opts) do - pool_mod = opts[:sandbox_pool] - - case pool_mod.checkout(pool, opts) do - {:ok, pool_ref, conn_mod, conn_state} -> - case conn_mod.handle_begin([mode: :transaction] ++ opts, conn_state) do - {:ok, _, conn_state} -> - {:ok, pool_ref, Connection, {conn_mod, conn_state, false, {[], []}}} - {_error_or_disconnect, err, conn_state} -> - pool_mod.disconnect(pool_ref, err, conn_state, opts) - end - error -> - error - end - end - - def checkin(pool_ref, {conn_mod, conn_state, _in_transaction?, _log_state}, opts) do - pool_mod = opts[:sandbox_pool] - case conn_mod.handle_rollback([mode: :transaction] ++ opts, conn_state) do - {:ok, _, conn_state} -> - pool_mod.checkin(pool_ref, conn_state, opts) - {_error_or_disconnect, err, conn_state} -> - pool_mod.disconnect(pool_ref, err, conn_state, opts) - end - end - - def disconnect(owner, exception, {_conn_mod, conn_state, _in_transaction?, _log_state}, opts) do - opts[:sandbox_pool].disconnect(owner, exception, conn_state, opts) - end - - def stop(owner, reason, {_conn_mod, conn_state, _in_transaction?, _log_state}, opts) do - opts[:sandbox_pool].stop(owner, reason, conn_state, opts) - end end @doc """ Sets the mode for the `repo` pool. - The mode can be `:auto`, `:manual` or `:shared`. + The mode can be `:auto`, `:manual` or `{:shared, }`. + + Warning: you should only call this function in the setup block for a test and + not within a test, because if the mode is changed during the test it will cause + other database connections to be checked in (causing errors). """ def mode(repo, mode) - when mode in [:auto, :manual] - when elem(mode, 0) == :shared and is_pid(elem(mode, 1)) do - {_repo_mod, name, opts} = Ecto.Registry.lookup(repo) - - if opts[:pool] != DBConnection.Ownership do - raise """ - cannot configure sandbox with pool #{inspect opts[:pool]}. - To use the SQL Sandbox, configure your repository pool as: - - pool: #{inspect __MODULE__} - """ - end - - # If the mode is set to anything but shared, let's - # automatically checkin the current connection to - # force it to act according to the chosen mode. - if mode in [:auto, :manual] do - checkin(repo, []) - end - - DBConnection.Ownership.ownership_mode(name, mode, opts) + when is_atom(repo) and mode in [:auto, :manual] + when is_atom(repo) and elem(mode, 0) == :shared and is_pid(elem(mode, 1)) do + %{pid: pool, opts: opts} = lookup_meta!(repo) + DBConnection.Ownership.ownership_mode(pool, mode, opts) end @doc """ @@ -516,30 +509,40 @@ defmodule EctoReplaySandbox do * `:sandbox` - when true the connection is wrapped in a transaction. Defaults to true. - * `:isolation` - set the query to the given isolation level + * `:isolation` - set the query to the given isolation level. * `:ownership_timeout` - limits how long the connection can be - owned. Defaults to the compiled value from your repo config in + owned. Defaults to the value in your repo config in `config/config.exs` (or preferably in `config/test.exs`), or - 15000 ms if not set. + 60000 ms if not set. The timeout exists for sanity checking + purposes, to ensure there is no connection leakage, and can + be bumped whenever necessary. + """ - def checkout(repo, opts \\ []) do - {_repo_mod, name, pool_opts} = + def checkout(repo, opts \\ []) when is_atom(repo) do + %{pid: pool, opts: pool_opts} = lookup_meta!(repo) + + pool_opts = if Keyword.get(opts, :sandbox, true) do - proxy_pool(repo) + [ + post_checkout: &post_checkout(&1, &2, opts), + pre_checkin: &pre_checkin(&1, &2, &3, opts) + ] ++ pool_opts else - Ecto.Registry.lookup(repo) + pool_opts end pool_opts_overrides = Keyword.take(opts, [:ownership_timeout]) pool_opts = Keyword.merge(pool_opts, pool_opts_overrides) - case DBConnection.Ownership.ownership_checkout(name, pool_opts) do + case DBConnection.Ownership.ownership_checkout(pool, pool_opts) do :ok -> if isolation = opts[:isolation] do set_transaction_isolation_level(repo, isolation) end + :ok + other -> other end @@ -547,9 +550,11 @@ defmodule EctoReplaySandbox do defp set_transaction_isolation_level(repo, isolation) do query = "SET TRANSACTION ISOLATION LEVEL #{isolation}" + case Ecto.Adapters.SQL.query(repo, query, [], sandbox_subtransaction: false) do {:ok, _} -> :ok + {:error, error} -> checkin(repo, []) raise error @@ -559,33 +564,26 @@ defmodule EctoReplaySandbox do @doc """ Checks in the connection back into the sandbox pool. """ - def checkin(repo, _opts \\ []) do - {_repo_mod, name, opts} = Ecto.Registry.lookup(repo) - DBConnection.Ownership.ownership_checkin(name, opts) + def checkin(repo, _opts \\ []) when is_atom(repo) do + %{pid: pool, opts: opts} = lookup_meta!(repo) + DBConnection.Ownership.ownership_checkin(pool, opts) end @doc """ Allows the `allow` process to use the same connection as `parent`. """ - def allow(repo, parent, allow, _opts \\ []) do - {_repo_mod, name, opts} = Ecto.Registry.lookup(repo) - DBConnection.Ownership.ownership_allow(name, parent, allow, opts) - end - - def ensure_all_started(app, type \\ :temporary) do - DBConnection.Ownership.ensure_all_started(app, type) - end - - def child_spec(module, opts, child_opts) do - DBConnection.Ownership.child_spec(module, opts, child_opts) + def allow(repo, parent, allow, _opts \\ []) when is_atom(repo) do + %{pid: pool, opts: opts} = lookup_meta!(repo) + DBConnection.Ownership.ownership_allow(pool, parent, allow, opts) end @doc """ Runs a function outside of the sandbox. """ - def unboxed_run(repo, fun) do + def unboxed_run(repo, fun) when is_atom(repo) do checkin(repo) checkout(repo, sandbox: false) + try do fun.() after @@ -593,9 +591,63 @@ defmodule EctoReplaySandbox do end end - defp proxy_pool(repo) do - {repo_mod, name, opts} = Ecto.Registry.lookup(repo) - {pool, opts} = Keyword.pop(opts, :ownership_pool, DBConnection.Poolboy) - {repo_mod, name, [repo: repo, sandbox_pool: pool, ownership_pool: Pool] ++ opts} + defp lookup_meta!(repo) do + %{opts: opts} = meta = Ecto.Adapter.lookup_meta(repo) + + if opts[:pool] != DBConnection.Ownership do + raise """ + cannot invoke sandbox operation with pool #{inspect(opts[:pool])}. + To use the SQL Sandbox, configure your repository pool as: + + pool: #{inspect(__MODULE__)} + """ + end + + meta + end + + defp post_checkout(conn_mod, conn_state, opts) do + case conn_mod.handle_begin([mode: :transaction] ++ opts, conn_state) do + {:ok, _, conn_state} -> + {:ok, Connection, {conn_mod, conn_state, false, {[], []}}} + + {_error_or_disconnect, err, conn_state} -> + {:disconnect, err, conn_mod, conn_state} + end + end + + defp pre_checkin( + :checkin, + Connection, + {conn_mod, conn_state, _in_transaction?, _log_state}, + opts + ) do + case conn_mod.handle_rollback([mode: :transaction] ++ opts, conn_state) do + {:ok, _, conn_state} -> + {:ok, conn_mod, conn_state} + + {:idle, _conn_state} -> + raise """ + Ecto SQL sandbox transaction was already committed/rolled back. + + The sandbox works by running each test in a transaction and closing the\ + transaction afterwards. However, the transaction has already terminated.\ + Your test code is likely committing or rolling back transactions manually,\ + either by invoking procedures or running custom SQL commands. + + One option is to manually checkout a connection without a sandbox: + + Ecto.Adapters.SQL.Sandbox.checkout(repo, sandbox: false) + + But remember you will have to undo any database changes performed by such tests. + """ + + {_error_or_disconnect, err, conn_state} -> + {:disconnect, err, conn_mod, conn_state} + end + end + + defp pre_checkin(_, Connection, {conn_mod, conn_state, _in_transaction?, _log_state}, _opts) do + {:ok, conn_mod, conn_state} end -end \ No newline at end of file +end diff --git a/mix.exs b/mix.exs index 2ccb784..f3931a7 100644 --- a/mix.exs +++ b/mix.exs @@ -1,13 +1,13 @@ defmodule EctoReplaySandbox.Mixfile do use Mix.Project - @version "1.0.0" + @version "2.0.0" def project do [ app: :ecto_replay_sandbox, version: @version, - elixir: "~> 1.4", + elixir: "~> 1.5", build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, package: package(), @@ -35,11 +35,12 @@ defmodule EctoReplaySandbox.Mixfile do defp deps do [ - {:ex_doc, ">= 0.0.0", only: :dev}, - {:ecto, "~> 2.2"}, - {:db_connection, "~> 1.1"}, - # {:postgrex, "~> 0.13"}, - {:postgrex, "~> 0.14.0-dev", hex: :postgrex_cdb, override: true} + {:ex_doc, "~> 0.20", only: :dev}, + {:ecto, "~> 3.1"}, + {:ecto_sql, "~> 3.1"}, + {:ecto_cockroachdb, "~> 1.0"}, + {:db_connection, "~> 2.0"}, + {:postgrex, ">= 0.14.3"} ] end end diff --git a/mix.lock b/mix.lock index 8448c1b..0b22c5f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,16 @@ -%{"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, - "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, - "decimal": {:hex, :decimal, "1.4.0", "fac965ce71a46aab53d3a6ce45662806bdd708a4a95a65cde8a12eb0124a1333", [:mix], []}, - "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], []}, - "ecto": {:hex, :ecto, "2.2.4", "defde3c8eca385bd86466d2e1491d19e77f9b79ad996dc8e89e4e107f3942f40", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, - "ex_doc": {:hex, :ex_doc, "0.16.4", "4bf6b82d4f0a643b500366ed7134896e8cccdbab4d1a7a35524951b25b1ec9f0", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}, - "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, - "postgrex": {:hex, :postgrex_cdb, "0.13.3", "06b13cc193701ab43f8cb141fcc3a5a56fc21de5386abc77eb9006851c21181c", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}} +%{ + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, + "db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, + "ecto": {:hex, :ecto, "3.1.4", "69d852da7a9f04ede725855a35ede48d158ca11a404fe94f8b2fb3b2162cd3c9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "ecto_cockroachdb": {:hex, :ecto_cockroachdb, "1.0.0", "7f78652aef651d4f14f63e570185cdda8c6d0bb28f8d531ca97f5332c40d46ad", [:mix], [{:ecto, "~> 3.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.14.3", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"}, + "ecto_sql": {:hex, :ecto_sql, "3.1.3", "2c536139190492d9de33c5fefac7323c5eaaa82e1b9bf93482a14649042f7cd9", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, + "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, +} diff --git a/test/repo.exs b/test/repo.exs index 7161ff2..cf25ba9 100644 --- a/test/repo.exs +++ b/test/repo.exs @@ -1,17 +1,23 @@ defmodule EctoReplaySandbox.Integration.Repo do defmacro __using__(opts) do quote do - config = Application.get_env(:ecto_replay_sandbox, __MODULE__) - config = Keyword.put(config, :loggers, [Ecto.LogEntry, - {EctoReplaySandbox.Integration.Repo, :log, [:on_log]}]) - Application.put_env(:ecto_replay_sandbox, __MODULE__, config) use Ecto.Repo, unquote(opts) + + @query_event __MODULE__ + |> Module.split() + |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom())) + |> Kernel.++([:query]) + + def init(_, opts) do + fun = &EctoReplaySandbox.Integration.Repo.handle_event/4 + :telemetry.attach_many(__MODULE__, [[:custom], @query_event], fun, :ok) + {:ok, opts} + end end end - def log(entry, key) do - on_log = Process.delete(key) || fn _ -> :ok end - on_log.(entry) - entry + def handle_event(event, latency, metadata, _config) do + handler = Process.delete(:telemetry) || fn _, _, _ -> :ok end + handler.(event, latency, metadata) end -end \ No newline at end of file +end diff --git a/test/ecto_replay_sandbox_test.exs b/test/sandbox_test.exs similarity index 54% rename from test/ecto_replay_sandbox_test.exs rename to test/sandbox_test.exs index 8b63501..aada266 100644 --- a/test/ecto_replay_sandbox_test.exs +++ b/test/sandbox_test.exs @@ -2,53 +2,79 @@ defmodule EctoReplaySandboxTest do use ExUnit.Case alias EctoReplaySandbox, as: Sandbox - alias EctoReplaySandbox.Integration.TestRepo + alias EctoReplaySandbox.Integration.{PoolRepo, TestRepo} alias EctoReplaySandbox.Integration.Post import ExUnit.CaptureLog - test "include link to SQL sandbox on ownership errors" do - assert_raise DBConnection.OwnershipError, - ~r"See Ecto.Adapters.SQL.Sandbox docs for more information.", fn -> - TestRepo.all(Post) + describe "errors" do + test "raises if repo is not started or not exist" do + assert_raise RuntimeError, + ~r"could not lookup UnknownRepo because it was not started", + fn -> + Sandbox.mode(UnknownRepo, :manual) + end end - end - test "can use the repository when checked out" do - assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> - TestRepo.all(Post) + test "raises if repo is not using sandbox" do + assert_raise RuntimeError, ~r"cannot invoke sandbox operation with pool DBConnection", fn -> + Sandbox.mode(PoolRepo, :manual) + end + + assert_raise RuntimeError, ~r"cannot invoke sandbox operation with pool DBConnection", fn -> + Sandbox.checkout(PoolRepo) + end end - Sandbox.checkout(TestRepo) - assert TestRepo.all(Post) == [] - Sandbox.checkin(TestRepo) - assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> - TestRepo.all(Post) + + test "include link to SQL sandbox on ownership errors" do + assert_raise DBConnection.OwnershipError, + ~r"See Ecto.Adapters.SQL.Sandbox docs for more information.", + fn -> + TestRepo.all(Post) + end end end - test "can use the repository when allowed from another process" do - assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> - TestRepo.all(Post) - end + describe "mode" do + test "uses the repository when checked out" do + assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> + TestRepo.all(Post) + end - parent = self() - Task.start_link fn -> Sandbox.checkout(TestRepo) - Sandbox.allow(TestRepo, self(), parent) - send parent, :allowed - :timer.sleep(:infinity) + assert TestRepo.all(Post) == [] + Sandbox.checkin(TestRepo) + + assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> + TestRepo.all(Post) + end end - assert_receive :allowed - assert TestRepo.all(Post) == [] - end + test "uses the repository when allowed from another process" do + assert_raise DBConnection.OwnershipError, ~r"cannot find ownership process", fn -> + TestRepo.all(Post) + end - test "can use the repository when shared from another process" do - Sandbox.checkout(TestRepo) - Sandbox.mode(TestRepo, {:shared, self()}) - assert Task.async(fn -> TestRepo.all(Post) end) |> Task.await == [] - after - Sandbox.mode(TestRepo, :manual) + parent = self() + + Task.start_link(fn -> + Sandbox.checkout(TestRepo) + Sandbox.allow(TestRepo, self(), parent) + send(parent, :allowed) + Process.sleep(:infinity) + end) + + assert_receive :allowed + assert TestRepo.all(Post) == [] + end + + test "uses the repository when shared from another process" do + Sandbox.checkout(TestRepo) + Sandbox.mode(TestRepo, {:shared, self()}) + assert Task.async(fn -> TestRepo.all(Post) end) |> Task.await() == [] + after + Sandbox.mode(TestRepo, :manual) + end end test "runs inside a sandbox that is rolled back on checkin" do @@ -78,20 +104,22 @@ defmodule EctoReplaySandboxTest do test "transaction works inside the sandbox" do Sandbox.checkout(TestRepo) - TestRepo.transaction fn -> + + TestRepo.transaction(fn -> TestRepo.all(Post) - end + end) + Sandbox.checkin(TestRepo) end test "works even with failed queries" do Sandbox.checkout(TestRepo) - {:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true) + {:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true) # This is a failed query but it should not taint the sandbox transaction {:error, _} = TestRepo.query("INVALID") - {:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true) - assert TestRepo.all(Post) |> Enum.count == 2 + {:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true) + assert TestRepo.all(Post) |> Enum.count() == 2 Sandbox.checkin(TestRepo) end @@ -99,11 +127,12 @@ defmodule EctoReplaySandboxTest do test "the failed transaction is properly rollbacked" do Sandbox.checkout(TestRepo) - TestRepo.transaction fn -> + TestRepo.transaction(fn -> TestRepo.insert(%Post{}) # This is a failed query to trigger a rollback {:error, _} = TestRepo.query("INVALID") - end + end) + assert TestRepo.all(Post) == [] Sandbox.checkin(TestRepo) @@ -113,10 +142,12 @@ defmodule EctoReplaySandboxTest do Sandbox.checkout(TestRepo) {:ok, _} = TestRepo.insert(%Post{}, skip_transaction: true) - TestRepo.transaction fn -> + + TestRepo.transaction(fn -> # This is a failed query to trigger a rollback {:error, _} = TestRepo.query("INVALID") - end + end) + assert TestRepo.all(Post) != [] Sandbox.checkin(TestRepo) @@ -127,11 +158,11 @@ defmodule EctoReplaySandboxTest do {:ok, _} = TestRepo.insert(%Post{id: 1}, skip_transaction: true) - TestRepo.transaction fn -> + TestRepo.transaction(fn -> %Post{} |> Post.changeset(%{id: 1}) - |> TestRepo.insert - end + |> TestRepo.insert() + end) TestRepo.all(Post) @@ -159,25 +190,26 @@ defmodule EctoReplaySandboxTest do assert TestRepo.insert(%Post{}) parent = self() - Task.start_link fn -> + Task.start_link(fn -> Sandbox.allow(TestRepo, parent, self()) assert [_] = TestRepo.all(Post) |> TestRepo.preload([:author, :comments]) - send parent, :success - end + send(parent, :success) + end) assert_receive :success end test "allows an ownership timeout to be passed for an individual `checkout` call" do - log = capture_log fn -> - :ok = Sandbox.checkout(TestRepo, ownership_timeout: 20) + log = + capture_log(fn -> + :ok = Sandbox.checkout(TestRepo, ownership_timeout: 20) - Process.sleep(1000) + Process.sleep(1000) - assert_raise DBConnection.OwnershipError, fn -> - TestRepo.all(Post) - end - end + assert_raise DBConnection.OwnershipError, fn -> + TestRepo.all(Post) + end + end) assert log =~ ~r/timed out.*20ms/ end diff --git a/test/schema.exs b/test/schema.exs index 8a02007..1fec2b5 100644 --- a/test/schema.exs +++ b/test/schema.exs @@ -5,7 +5,8 @@ defmodule EctoReplaySandbox.Integration.Schema do type = Application.get_env(:ecto, :primary_key_type) || - raise ":primary_key_type not set in :ecto application" + raise ":primary_key_type not set in :ecto application" + @primary_key {:id, type, autogenerate: true} @foreign_key_type type @timestamps_opts [usec: false] @@ -24,9 +25,20 @@ defmodule EctoReplaySandbox.Integration.User do use EctoReplaySandbox.Integration.Schema schema "users" do - field :name, :string - has_many :comments, EctoReplaySandbox.Integration.Comment, foreign_key: :author_id, on_delete: :nilify_all, on_replace: :nilify - has_many :posts, EctoReplaySandbox.Integration.Post, foreign_key: :author_id, on_delete: :nothing, on_replace: :delete + field(:name, :string) + + has_many(:comments, EctoReplaySandbox.Integration.Comment, + foreign_key: :author_id, + on_delete: :nilify_all, + on_replace: :nilify + ) + + has_many(:posts, EctoReplaySandbox.Integration.Post, + foreign_key: :author_id, + on_delete: :nothing, + on_replace: :delete + ) + timestamps(type: :utc_datetime) end end @@ -46,23 +58,29 @@ defmodule EctoReplaySandbox.Integration.Post do import Ecto.Changeset schema "posts" do - field :counter, :id # Same as integer - field :title, :string - field :text, :binary - field :temp, :string, default: "temp", virtual: true - field :public, :boolean, default: true - field :cost, :decimal - field :visits, :integer - field :intensity, :float - field :posted, :date - has_many :comments, EctoReplaySandbox.Integration.Comment, on_delete: :delete_all, on_replace: :delete - belongs_to :author, EctoReplaySandbox.Integration.User + # Same as integer + field(:counter, :id) + field(:title, :string) + field(:text, :binary) + field(:temp, :string, default: "temp", virtual: true) + field(:public, :boolean, default: true) + field(:cost, :decimal) + field(:visits, :integer) + field(:intensity, :float) + field(:posted, :date) + + has_many(:comments, EctoReplaySandbox.Integration.Comment, + on_delete: :delete_all, + on_replace: :delete + ) + + belongs_to(:author, EctoReplaySandbox.Integration.User) timestamps() end - def changeset(schema, params) do - cast(schema, params, ~w(counter title text temp public cost visits - intensity posted)) + def changeset(post, params) do + post + |> cast(params, ~w(counter title text temp public cost visits intensity posted)a) end end @@ -75,14 +93,16 @@ defmodule EctoReplaySandbox.Integration.Comment do """ use EctoReplaySandbox.Integration.Schema + import Ecto.Changeset schema "comments" do - field :text, :string - belongs_to :post, EctoReplaySandbox.Integration.Post - belongs_to :author, EctoReplaySandbox.Integration.User + field(:text, :string) + belongs_to(:post, EctoReplaySandbox.Integration.Post) + belongs_to(:author, EctoReplaySandbox.Integration.User) end - def changeset(schema, params) do - Ecto.Changeset.cast(schema, params, [:text]) + def changeset(comment, params) do + comment + |> cast(params, [:text]) end -end \ No newline at end of file +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 6a0ad18..9a06baa 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,50 +1,66 @@ -Code.compiler_options(ignore_module_conflict: true) - Logger.configure(level: :info) ExUnit.configure(exclude: [:pending, :without_conflict_target]) -ExUnit.start() # Configure Ecto for support and tests -Application.put_env(:ecto, :lock_for_update, "FOR UPDATE") Application.put_env(:ecto, :primary_key_type, :id) +Application.put_env(:ecto, :async_integration_tests, true) +Application.put_env(:ecto_replay_sandbox, :lock_for_update, nil) # Configure CockroachDB connection -Application.put_env(:ecto_replay_sandbox, :cdb_test_url, +Application.put_env( + :ecto_replay_sandbox, + :cdb_test_url, "ecto://" <> (System.get_env("CDB_URL") || "root@localhost:26257") ) # Load support files -Code.require_file "./repo.exs", __DIR__ -Code.require_file "./schema.exs", __DIR__ -Code.require_file "./migration.exs", __DIR__ - -pool = - case System.get_env("ECTO_POOL") || "poolboy" do - "poolboy" -> DBConnection.Poolboy - "sbroker" -> DBConnection.Sojourn - end +Code.require_file("./repo.exs", __DIR__) # Pool repo for async, safe tests alias EctoReplaySandbox.Integration.TestRepo Application.put_env(:ecto_replay_sandbox, TestRepo, - adapter: Ecto.Adapters.Postgres, url: Application.get_env(:ecto_replay_sandbox, :cdb_test_url) <> "/ecto_replay_sandbox_test", - pool: EctoReplaySandbox, - ownership_pool: pool) + pool: EctoReplaySandbox +) defmodule EctoReplaySandbox.Integration.TestRepo do - use EctoReplaySandbox.Integration.Repo, otp_app: :ecto_replay_sandbox + use EctoReplaySandbox.Integration.Repo, + otp_app: :ecto_replay_sandbox, + adapter: Ecto.Adapters.CockroachDB +end + +# Pool repo for non-async tests +alias EctoReplaySandbox.Integration.PoolRepo + +Application.put_env(:ecto_replay_sandbox, PoolRepo, + url: Application.get_env(:ecto_replay_sandbox, :cdb_test_url) <> "/ecto_replay_sandbox_test", + pool_size: 10, + max_restarts: 20, + max_seconds: 10 +) + +defmodule EctoReplaySandbox.Integration.PoolRepo do + use EctoReplaySandbox.Integration.Repo, + otp_app: :ecto_replay_sandbox, + adapter: Ecto.Adapters.CockroachDB end -{:ok, _} = Ecto.Adapters.Postgres.ensure_all_started(TestRepo, :temporary) +# Load support files +Code.require_file("./schema.exs", __DIR__) +Code.require_file("./migration.exs", __DIR__) + +{:ok, _} = Ecto.Adapters.CockroachDB.ensure_all_started(TestRepo.config(), :temporary) # Load up the repository, start it, and run migrations -_ = Ecto.Adapters.Postgres.storage_down(TestRepo.config) -:ok = Ecto.Adapters.Postgres.storage_up(TestRepo.config) +_ = Ecto.Adapters.CockroachDB.storage_down(TestRepo.config()) +:ok = Ecto.Adapters.CockroachDB.storage_up(TestRepo.config()) -{:ok, _pid} = TestRepo.start_link +{:ok, _pid} = TestRepo.start_link() +{:ok, _pid} = PoolRepo.start_link() :ok = Ecto.Migrator.up(TestRepo, 0, EctoReplaySandbox.Integration.Migration, log: false) EctoReplaySandbox.mode(TestRepo, :manual) Process.flag(:trap_exit, true) + +ExUnit.start()