This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
defmodule Phoenix.Ecto do
@moduledoc """
Integrates Phoenix with Ecto.
It implements many protocols that make it easier to use
Ecto with Phoenix either when working with HTML or JSON.
"""
use Application
def start(_type, _args) do
children = [
{DynamicSupervisor, name: Phoenix.Ecto.SQL.SandboxSupervisor, strategy: :one_for_one}
]
Supervisor.start_link(children, strategy: :one_for_one, name: Phoenix.Ecto.Supervisor)
end
end

View File

@@ -0,0 +1,107 @@
defmodule Phoenix.Ecto.CheckRepoStatus do
@moduledoc """
A plug that does some checks on your application repos.
Checks if the storage is up (database is created) or if there are any pending migrations.
Both checks can raise an error if the conditions are not met.
## Plug options
* `:otp_app` - name of the application which the repos are fetched from
* `:migration_paths` - a function that accepts a repo and returns a migration directory, or a list of migration directories, that is used to check for pending migrations
* `:migration_lock` - the locking strategy used by the Ecto Adapter when checking for pending migrations. Defaults to `false`. Set to `true` to enable migration locks.
* `:prefix` - the prefix used to check for pending migrations.
* `:skip_table_creation` - Ecto will not try to create the `schema_migrations` table automatically. This is useful if you are connecting as a DB user without create permissions
"""
@behaviour Plug
alias Plug.Conn
@migration_opts [:migration_lock, :prefix, :skip_table_creation]
@compile {:no_warn_undefined, Ecto.Migrator}
def init(opts) do
Keyword.fetch!(opts, :otp_app)
opts
end
def call(%Conn{} = conn, opts) do
try do
repos = Application.get_env(opts[:otp_app], :ecto_repos, [])
for repo <- repos, Process.whereis(repo) do
check_pending_migrations!(repo, opts) || check_storage_up!(repo)
end
conn
rescue
err in [Phoenix.Ecto.StorageNotCreatedError, Phoenix.Ecto.PendingMigrationError] ->
Plug.Conn.WrapperError.reraise(conn, :error, err, __STACKTRACE__)
end
end
defp check_storage_up!(repo) do
try do
adapter = repo.__adapter__()
if Code.ensure_loaded?(adapter) && function_exported?(adapter, :storage_status, 1) do
adapter.storage_status(repo.config())
end
rescue
_ -> true
else
:down -> raise Phoenix.Ecto.StorageNotCreatedError, repo: repo
_ -> true
end
end
defp check_pending_migrations!(repo, opts) do
dirs = migration_directories(repo, opts)
migrations_fun =
Keyword.get_lazy(opts, :mock_migrations_fn, fn ->
if Code.ensure_loaded?(Ecto.Migrator),
do: &Ecto.Migrator.migrations/3,
else: fn _repo, _paths, _opts -> raise "to be rescued" end
end)
true = is_function(migrations_fun, 3)
migration_opts =
opts
|> Keyword.take(@migration_opts)
|> Keyword.put_new(:migration_lock, false)
try do
repo
|> migrations_fun.(dirs, migration_opts)
|> Enum.any?(fn {status, _version, _migration} -> status == :down end)
rescue
_ -> false
else
true ->
raise Phoenix.Ecto.PendingMigrationError,
repo: repo,
directories: dirs,
migration_opts: migration_opts
false ->
true
end
end
defp migration_directories(repo, opts) do
case Keyword.fetch(opts, :migration_paths) do
{:ok, migration_directories_fn} ->
List.wrap(migration_directories_fn.(repo))
:error ->
try do
[Ecto.Migrator.migrations_path(repo)]
rescue
_ -> []
end
end
end
end

View File

@@ -0,0 +1,18 @@
defmodule Phoenix.Ecto.StorageNotCreatedError do
defexception [:repo]
def message(%__MODULE__{repo: repo}) do
"the storage is not created for repo: #{inspect(repo)}. " <>
"Try running `mix ecto.create` in the command line to create it"
end
end
defmodule Phoenix.Ecto.PendingMigrationError do
@enforce_keys [:repo, :directories, :migration_opts]
defexception [:repo, :directories, :migration_opts]
def message(%__MODULE__{repo: repo}) do
"there are pending migrations for repo: #{inspect(repo)}. " <>
"Try running `mix ecto.migrate` in the command line to migrate it"
end
end

View File

@@ -0,0 +1,345 @@
if Code.ensure_loaded?(Phoenix.HTML) do
defimpl Phoenix.HTML.FormData, for: Ecto.Changeset do
def to_form(changeset, opts) do
%{params: params, data: data, action: action} = changeset
{action, opts} = Keyword.pop(opts, :action, action)
{name, opts} = Keyword.pop(opts, :as)
name = to_string(name || form_for_name(data))
id = Keyword.get(opts, :id) || name
%Phoenix.HTML.Form{
source: changeset,
impl: __MODULE__,
id: id,
action: action,
name: name,
errors: form_for_errors(changeset, action),
data: data,
params: params || %{},
hidden: form_for_hidden(data),
options: Keyword.put_new(opts, :method, form_for_method(data))
}
end
def to_form(source, %{action: parent_action} = form, field, opts) do
if Keyword.has_key?(opts, :default) do
raise ArgumentError,
":default is not supported on inputs_for with changesets. " <>
"The default value must be set in the changeset data"
end
{prepend, opts} = Keyword.pop(opts, :prepend, [])
{append, opts} = Keyword.pop(opts, :append, [])
{name, opts} = Keyword.pop(opts, :as)
{id, opts} = Keyword.pop(opts, :id)
id = to_string(id || form.id <> "_#{field}")
name = to_string(name || form.name <> "[#{field}]")
case find_inputs_for_type!(source, field) do
{:one, cast, module} ->
changesets =
case Map.fetch(source.changes, field) do
{:ok, nil} ->
[]
{:ok, map} ->
[validate_map!(map, field)]
_ ->
[validate_map!(assoc_from_data(source.data, field), field) || module.__struct__()]
end
for changeset <- skip_replaced(changesets) do
%{data: data, params: params} =
changeset = to_changeset(changeset, parent_action, module, cast, nil)
%Phoenix.HTML.Form{
source: changeset,
action: parent_action,
impl: __MODULE__,
id: id,
name: name,
errors: form_for_errors(changeset, parent_action),
data: data,
params: params || %{},
hidden: form_for_hidden(data),
options: opts
}
end
{:many, cast, module} ->
changesets =
validate_list!(Map.get(source.changes, field), field) ||
validate_list!(assoc_from_data(source.data, field), field) || []
changesets =
if form.params[Atom.to_string(field)] do
changesets
else
prepend ++ changesets ++ append
end
changesets = skip_replaced(changesets)
for {changeset, index} <- Enum.with_index(changesets) do
%{data: data, params: params} =
changeset = to_changeset(changeset, parent_action, module, cast, index)
index_string = Integer.to_string(index)
%Phoenix.HTML.Form{
source: changeset,
impl: __MODULE__,
action: parent_action,
id: id <> "_" <> index_string,
name: name <> "[" <> index_string <> "]",
index: index,
errors: form_for_errors(changeset, parent_action),
data: data,
params: params || %{},
hidden: form_for_hidden(data),
options: opts
}
end
end
end
def input_value(%{changes: changes, data: data}, %{params: params}, field)
when is_atom(field) do
case changes do
%{^field => value} ->
value
%{} ->
string = Atom.to_string(field)
case params do
%{^string => value} -> value
%{} -> Map.get(data, field)
end
end
end
def input_value(_data, _form, field) do
raise ArgumentError, "expected field to be an atom, got: #{inspect(field)}"
end
def input_type(%{types: types}, _, field) do
type = Map.get(types, field, :string)
type = if Ecto.Type.primitive?(type), do: type, else: type.type()
case type do
:integer -> :number_input
:boolean -> :checkbox
:date -> :date_select
:time -> :time_select
:utc_datetime -> :datetime_select
:naive_datetime -> :datetime_select
_ -> :text_input
end
end
def input_validations(%{required: required, validations: validations} = changeset, _, field) do
[required: field in required] ++
for {key, validation} <- validations,
key == field,
attr <- validation_to_attrs(validation, field, changeset),
do: attr
end
defp assoc_from_data(data, field) do
assoc_from_data(data, Map.fetch!(data, field), field)
end
defp assoc_from_data(%{__meta__: %{state: :built}}, %Ecto.Association.NotLoaded{}, _field) do
nil
end
defp assoc_from_data(%{__struct__: struct}, %Ecto.Association.NotLoaded{}, field) do
raise ArgumentError,
"using inputs_for for association `#{field}` " <>
"from `#{inspect(struct)}` but it was not loaded. Please preload your " <>
"associations before using them in inputs_for"
end
defp assoc_from_data(_data, value, _field) do
value
end
defp skip_replaced(changesets) do
Enum.reject(changesets, fn
%Ecto.Changeset{action: :replace} -> true
_ -> false
end)
end
defp validation_to_attrs({:length, opts}, _field, _changeset) do
max =
if val = Keyword.get(opts, :max) do
[maxlength: val]
else
[]
end
min =
if val = Keyword.get(opts, :min) do
[minlength: val]
else
[]
end
max ++ min
end
defp validation_to_attrs({:number, opts}, field, changeset) do
type = Map.get(changeset.types, field, :integer)
step_for(type) ++ min_for(type, opts) ++ max_for(type, opts)
end
defp validation_to_attrs(_validation, _field, _changeset) do
[]
end
defp step_for(:integer), do: [step: 1]
defp step_for(_other), do: [step: "any"]
defp max_for(type, opts) do
cond do
max = type == :integer && Keyword.get(opts, :less_than) -> [max: max - 1]
max = Keyword.get(opts, :less_than_or_equal_to) -> [max: max]
true -> []
end
end
defp min_for(type, opts) do
cond do
min = type == :integer && Keyword.get(opts, :greater_than) -> [min: min + 1]
min = Keyword.get(opts, :greater_than_or_equal_to) -> [min: min]
true -> []
end
end
defp find_inputs_for_type!(changeset, field) do
case Map.fetch(changeset.types, field) do
{:ok, {tag, %{cardinality: cardinality, on_cast: cast, related: module}}}
when tag in [:embed, :assoc] ->
{cardinality, cast, module}
_ ->
struct = changeset.data.__struct__
raise ArgumentError,
"could not generate inputs for #{inspect(field)} from #{inspect(struct)}. " <>
"Check the field exists and it is one of embeds_one, embeds_many, has_one, " <>
"has_many, belongs_to or many_to_many"
end
end
defp to_changeset(%Ecto.Changeset{} = changeset, parent_action, _module, _cast, _index),
do: apply_action(changeset, parent_action)
defp to_changeset(%{} = data, parent_action, _module, cast, _index) when is_function(cast, 2),
do: apply_action(cast!(cast, data), parent_action)
defp to_changeset(%{} = data, parent_action, _module, cast, index) when is_function(cast, 3),
do: apply_action(cast!(cast, data, index), parent_action)
defp to_changeset(%{} = data, parent_action, _module, {module, func, arguments} = mfa, _index)
when is_atom(module) and is_atom(func) and is_list(arguments),
do: apply_action(apply!(mfa, data), parent_action)
defp to_changeset(%{} = data, parent_action, _module, nil, _index),
do: apply_action(Ecto.Changeset.change(data), parent_action)
defp cast!(cast, data) do
case cast.(data, %{}) do
%Ecto.Changeset{} = changeset ->
changeset
other ->
raise "expected on_cast/2 callback #{inspect(cast)} to return an Ecto.Changeset, " <>
"got: #{inspect(other)}"
end
end
defp cast!(cast, data, index) do
case cast.(data, %{}, index) do
%Ecto.Changeset{} = changeset ->
changeset
other ->
raise "expected on_cast/3 callback #{inspect(cast)} to return an Ecto.Changeset, " <>
"got: #{inspect(other)}"
end
end
defp apply!({module, func, arguments}, data) do
case apply(module, func, [data, %{} | arguments]) do
%Ecto.Changeset{} = changeset ->
changeset
other ->
raise "expected #{module}.#{func} to return an Ecto.Changeset, " <>
"got: #{inspect(other)}"
end
end
# If the parent changeset had no action, we need to remove the action
# from children changeset so we ignore all errors accordingly.
defp apply_action(changeset, nil),
do: %{changeset | action: nil}
defp apply_action(changeset, _action),
do: changeset
defp validate_list!(value, _what) when is_list(value) or is_nil(value), do: value
defp validate_list!(value, what) do
raise ArgumentError, "expected #{what} to be a list, got: #{inspect(value)}"
end
defp validate_map!(value, _what) when is_map(value) or is_nil(value), do: value
defp validate_map!(value, what) do
raise ArgumentError, "expected #{what} to be a map/struct, got: #{inspect(value)}"
end
defp form_for_errors(_changeset, nil = _action), do: []
defp form_for_errors(_changeset, :ignore = _action), do: []
defp form_for_errors(%Ecto.Changeset{errors: errors}, _action), do: errors
defp form_for_hidden(%{__struct__: module} = data) do
module.__schema__(:primary_key)
rescue
_ -> []
else
keys -> for k <- keys, v = Map.fetch!(data, k), do: {k, v}
end
defp form_for_hidden(_), do: []
defp form_for_name(%{__struct__: module}) do
module
|> Module.split()
|> List.last()
|> Macro.underscore()
end
defp form_for_name(_) do
raise ArgumentError,
"cannot generate name for changeset where the data is not backed by a struct. " <>
"You must either pass the :as option to form/form_for or use a struct-based changeset"
end
defp form_for_method(%{__meta__: %{state: :loaded}}), do: "put"
defp form_for_method(_), do: "post"
end
defimpl Phoenix.HTML.Safe, for: Decimal do
def to_iodata(t) do
@for.to_string(t, :normal)
end
end
end

View File

@@ -0,0 +1,72 @@
errors = [
{Ecto.CastError, 400},
{Ecto.Query.CastError, 400},
{Ecto.NoResultsError, 404},
{Ecto.StaleEntryError, 409}
]
excluded_exceptions = Application.get_env(:phoenix_ecto, :exclude_ecto_exceptions_from_plug, [])
for {exception, status_code} <- errors do
unless exception in excluded_exceptions do
defimpl Plug.Exception, for: exception do
def status(_), do: unquote(status_code)
def actions(_), do: []
end
end
end
unless Ecto.SubQueryError in excluded_exceptions do
defimpl Plug.Exception, for: Ecto.SubQueryError do
def status(sub_query_error) do
Plug.Exception.status(sub_query_error.exception)
end
def actions(_), do: []
end
end
unless Phoenix.Ecto.PendingMigrationError in excluded_exceptions do
defimpl Plug.Exception, for: Phoenix.Ecto.PendingMigrationError do
def status(_error), do: 503
def actions(%{repo: repo, directories: directories, migration_opts: migration_opts}),
do: [
%{
label: "Run migrations for repo",
handler: {__MODULE__, :migrate, [repo, directories, migration_opts]}
}
]
def migrate(repo, directories, migration_opts) do
Ecto.Migrator.run(repo, directories, :up, Keyword.merge(migration_opts || [], all: true))
end
end
end
unless Phoenix.Ecto.StorageNotCreatedError in excluded_exceptions do
defimpl Plug.Exception, for: Phoenix.Ecto.StorageNotCreatedError do
def status(_error), do: 503
def actions(%{repo: repo}),
do: [
%{
label: "Create database for repo",
handler: {__MODULE__, :storage_up, [repo]}
}
]
def storage_up(repo), do: repo.__adapter__().storage_up(repo.config())
end
end
if Code.ensure_loaded?(Postgrex.Error) do
unless Postgrex.Error in excluded_exceptions do
defimpl Plug.Exception, for: Postgrex.Error do
def status(%{postgres: %{code: :character_not_in_repertoire}}), do: 400
def status(_), do: 500
def actions(_), do: []
end
end
end

View File

@@ -0,0 +1,353 @@
defmodule Phoenix.Ecto.SQL.Sandbox do
@moduledoc """
A plug to allow concurrent, transactional acceptance tests with
[`Ecto.Adapters.SQL.Sandbox`](https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.Sandbox.html).
## Example
This plug should only be used during tests. First, set a flag to
enable it in `config/test.exs`:
config :your_app, sql_sandbox: true
And use the flag to conditionally add the plug to `lib/your_app/endpoint.ex`:
if Application.compile_env(:your_app, :sql_sandbox) do
plug Phoenix.Ecto.SQL.Sandbox
end
It's important that this is at the top of `endpoint.ex`, before any other plugs.
Then, within an acceptance test, checkout a sandboxed connection as before.
Use `metadata_for/2` helper to get the session metadata to that will allow access
to the test's connection. In general lines, you would write this:
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(YourApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
metadata_header = Phoenix.Ecto.SQL.Sandbox.metadata_for(YourApp.Repo, pid)
# pass the metadata to the acceptance test library
:ok
end
You can follow the instructions for Wallaby [here](https://hexdocs.pm/wallaby/readme.html#phoenix).
## Acceptance tests with channels
To support channels, in addition the above, you need to make it so each channel
is allowed within the sandbox. The first step is to access the relevant header
metadata.
To do so, you must declare that you want to pass connection information to your
socket. This is typically the user agent header, but it can be a custom x-header
too:
socket "/path", Socket,
websocket: [connect_info: [:user_agent, …]]
socket "/path", Socket,
websocket: [connect_info: [:x_headers, …]]
Now use the `c:Phoenix.Socket.connect/3` callback to access the header and
store it in the socket:
# user_socket.ex
def connect(_params, socket, connect_info) do
{:ok, assign(socket, :phoenix_ecto_sandbox, connect_info.user_agent)}
end
Or if you are using a custom header:
Enum.find_value(connect_info.x_headers, fn
{"x-my-custom-header", val} -> val
_ -> nil
end)
This stores the value on the socket, so it can be available to all of your
channels for allowing the sandbox.
# room_channel.ex
def join("room:lobby", _payload, socket) do
allow_ecto_sandbox(socket)
{:ok, socket}
end
# This is a great function to extract to a helper module
defp allow_ecto_sandbox(socket) do
Phoenix.Ecto.SQL.Sandbox.allow(
socket.assigns.phoenix_ecto_sandbox,
Ecto.Adapters.SQL.Sandbox
)
end
`allow/2` needs to be manually called once for each channel, at best directly
at the start of `c:Phoenix.Channel.join/3`.
## Acceptance tests with LiveViews
LiveViews can be supported in a similar fashion to channels. First declare the
`:user_agent` (or a custom header) in your live socket configuration in `endpoint.ex`:
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [:user_agent, session: @session_options]]
Now you can use the `on_mount/4` callback to check the header and assign the sandbox:
defmodule MyApp.LiveAcceptance do
import Phoenix.LiveView
import Phoenix.Component
def on_mount(:default, _params, _session, socket) do
socket =
assign_new(socket, :phoenix_ecto_sandbox, fn ->
if connected?(socket), do: get_connect_info(socket, :user_agent)
end)
metadata = socket.assigns.phoenix_ecto_sandbox
Phoenix.Ecto.SQL.Sandbox.allow(metadata, Ecto.Adapters.SQL.Sandbox)
{:cont, socket}
end
end
Now, in your `my_app_web.ex` file, you can invoke this callback for all of your
LiveViews if the sandbox configuration, defined at the beginning of the
documentation, is enabled:
def live_view do
quote do
use Phoenix.LiveView
# ...
if Application.compile_env(:your_app, :sql_sandbox) do
on_mount MyApp.LiveAcceptance
end
# ...
end
end
If you have `on_mount` hooks in `live_session` defined in your `router.ex`
(for example, routes requiring authentication after running `mix phx.gen.auth`
to generate your authentication system), make sure the `MyApp.LiveAcceptance`
hook runs before, so following hooks have access to the Ecto Sandbox:
live_session :require_authenticated_user,
on_mount:
if(Application.compile_env(:your_app, :sql_sandbox),
do: [MyAppWeb.AcceptanceHook],
else: []
) ++ [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
## Concurrent end-to-end tests with external clients
Concurrent and transactional tests for external HTTP clients is supported,
allowing for complete end-to-end tests. This is useful for cases such as
JavaScript test suites for single page applications that exercise the
Phoenix endpoint for end-to-end test setup and teardown. To enable this,
you can expose a sandbox route on the `Phoenix.Ecto.SQL.Sandbox` plug by
providing the `:at`, and `:repo` options. For example:
plug Phoenix.Ecto.SQL.Sandbox,
at: "/sandbox",
repo: MyApp.Repo,
timeout: 15_000 # the default
This would expose a route at `"/sandbox"` for the given repo where
external clients send POST requests to spawn a new sandbox session,
and DELETE requests to stop an active sandbox session. By default,
the external client is expected to pass up the `"user-agent"` header
containing serialized sandbox metadata returned from the POST request,
but this value may customized with the `:header` option.
Finally, make sure your repository mode is set either to `:manual`
or `{:shared, self()}` before the external client starts. This is
typically done by default in your `test/test_helper.exs`, but you
may need to do it explicitly depending on your setup:
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
"""
import Plug.Conn
alias Plug.Conn
alias Phoenix.Ecto.SQL.{SandboxSession, SandboxSupervisor}
@doc """
Spawns a sandbox session to checkout a connection for a remote client.
## Examples
iex> {:ok, _owner_pid, metadata} = start_child(MyApp.Repo)
"""
def start_child(repos, opts \\ []) do
child_spec = {SandboxSession, {repos, self(), opts}}
case DynamicSupervisor.start_child(SandboxSupervisor, child_spec) do
{:ok, owner} ->
metadata = metadata_for(repos, owner)
{:ok, owner, metadata}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Stops a sandbox session holding a connection for a remote client.
## Examples
iex> {:ok, owner_pid, metadata} = start_child(MyApp.Repo)
iex> :ok = stop(owner_pid)
"""
def stop(owner) when is_pid(owner) do
GenServer.call(owner, :checkin)
end
@doc false
def init(opts \\ []) do
session_opts = Keyword.take(opts, [:sandbox, :timeout])
%{
header: Keyword.get(opts, :header, "user-agent"),
path: get_path_info(opts[:at]),
repos: List.wrap(opts[:repo]),
sandbox:
session_opts[:sandbox] || {Ecto.Adapters.SQL.Sandbox, :allow, [[unallow_existing: true]]},
session_opts: session_opts
}
end
defp get_path_info(nil), do: nil
defp get_path_info(path), do: Plug.Router.Utils.split(path)
@doc false
def call(%Conn{method: "POST", path_info: path} = conn, %{path: path} = opts) do
%{repos: repos, session_opts: session_opts} = opts
{:ok, _owner, metadata} = start_child(repos, session_opts)
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, encode_metadata(metadata))
|> halt()
end
def call(%Conn{method: "DELETE", path_info: path} = conn, %{path: path} = opts) do
case decode_metadata(extract_header(conn, opts.header)) do
%{owner: owner} ->
:ok = stop(owner)
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, "")
|> halt()
%{} ->
conn
|> send_resp(410, "")
|> halt()
end
end
def call(conn, %{header: header, sandbox: sandbox}) do
header = extract_header(conn, header)
allow(header, sandbox)
assign(conn, :phoenix_ecto_sandbox, header)
end
defp extract_header(%Conn{} = conn, header) do
conn |> get_req_header(header) |> List.first()
end
@doc """
Returns metadata to establish a sandbox for.
The metadata is then passed via user-agent/headers to browsers.
Upon request, the `Phoenix.Ecto.SQL.Sandbox` plug will decode
the header and allow the request process under the sandbox.
## Options
* `:trap_exit` - if the browser being used for integration
testing navigates away from a page or aborts a AJAX request
while the request process is talking to the database, it
will corrupt the database connection and make the test fail.
Therefore, to avoid intermittent tests, we recommend trapping
exits in the request process, so all database connections shut
down cleanly. You can disable this behaviour by setting the
option to false.
"""
@spec metadata_for(Ecto.Repo.t() | [Ecto.Repo.t()], pid, keyword) :: map
def metadata_for(repo_or_repos, pid, opts \\ []) when is_pid(pid) do
%{repo: repo_or_repos, owner: pid, trap_exit: Keyword.get(opts, :trap_exit, true)}
end
@doc """
Encodes metadata generated by `metadata_for/2` for client response.
"""
def encode_metadata(metadata) do
encoded =
{:v1, metadata}
|> :erlang.term_to_binary()
|> Base.url_encode64()
"BeamMetadata (#{encoded})"
end
@doc """
Decodes encoded metadata back into map generated from `metadata_for/2`.
"""
def decode_metadata(encoded_meta) when is_binary(encoded_meta) do
case encoded_meta |> String.split("/") |> List.last() do
"BeamMetadata (" <> metadata ->
metadata
|> binary_part(0, byte_size(metadata) - 1)
|> parse_metadata()
_ ->
%{}
end
end
def decode_metadata(_), do: %{}
defp parse_metadata(encoded_metadata) do
encoded_metadata
|> Base.url_decode64!()
|> :erlang.binary_to_term()
|> case do
{:v1, metadata} -> metadata
_ -> %{}
end
end
@doc """
Decodes the given metadata and allows the current process
under the given sandbox.
"""
def allow(encoded_metadata, sandbox) when is_binary(encoded_metadata) do
metadata = decode_metadata(encoded_metadata)
with %{trap_exit: true} <- metadata do
Process.flag(:trap_exit, true)
end
allow(metadata, sandbox)
end
def allow(%{repo: repo, owner: owner}, sandbox),
do: Enum.each(List.wrap(repo), &allow_sandbox(sandbox, &1, owner, self()))
def allow(%{}, _sandbox), do: :ok
def allow(nil, _sandbox), do: :ok
defp allow_sandbox({m, f, args}, repo, owner, pid),
do: apply(m, f, [repo, owner, pid | args])
defp allow_sandbox(sandbox, repo, owner, pid) when is_atom(sandbox),
do: sandbox.allow(repo, owner, pid)
end

View File

@@ -0,0 +1,51 @@
defmodule Phoenix.Ecto.SQL.SandboxSession do
@moduledoc false
use GenServer, restart: :temporary
@timeout 15_000
def start_link({repos, client, opts}) do
GenServer.start_link(__MODULE__, [repos, client, opts])
end
def init([repos, client, opts]) do
timeout = opts[:timeout] || @timeout
sandbox = opts[:sandbox] || Ecto.Adapters.SQL.Sandbox
:ok = checkout_connection(sandbox, repos, client)
Process.send_after(self(), :timeout, timeout)
{:ok, %{repos: repos, client: client, sandbox: sandbox}}
end
def handle_call(:checkin, _from, state) do
:ok = checkin_connection(state.sandbox, state.repos, state.client)
{:stop, :shutdown, :ok, state}
end
def handle_info(:timeout, state) do
:ok = checkin_connection(state.sandbox, state.repos, state.client)
{:stop, :shutdown, state}
end
def handle_info({:allowed, repo}, state) do
send(state.client, {:allowed, repo})
{:noreply, state}
end
defp checkin_connection(sandbox, repos, client) do
for repo <- repos do
:ok = sandbox.checkin(repo, client: client)
end
:ok
end
defp checkout_connection(sandbox, repos, client) do
for repo <- repos do
:ok = sandbox.checkout(repo, client: client)
end
:ok
end
end