update
This commit is contained in:
17
phoenix/deps/phoenix_ecto/lib/phoenix_ecto.ex
Normal file
17
phoenix/deps/phoenix_ecto/lib/phoenix_ecto.ex
Normal 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
|
||||
107
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/check_repo_status.ex
Normal file
107
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/check_repo_status.ex
Normal 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
|
||||
18
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/exceptions.ex
Normal file
18
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/exceptions.ex
Normal 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
|
||||
345
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/html.ex
Normal file
345
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/html.ex
Normal 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
|
||||
72
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/plug.ex
Normal file
72
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/plug.ex
Normal 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
|
||||
353
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/sql/sandbox.ex
Normal file
353
phoenix/deps/phoenix_ecto/lib/phoenix_ecto/sql/sandbox.ex
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user