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,4 @@
[
import_deps: [:ecto],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

Binary file not shown.

View File

@@ -0,0 +1,264 @@
# Changelog
## v4.7.0
* Bug fixes
* Disable migration lock when checking for pending migrations to avoid slow downs
## v4.6.6
* Bug fixes
* Keep backwards compatibility on sandbox API
## v4.6.5
* Bug fixes
* Unallow existing allowances when attempting to allow a Plug to access a connection
## v4.6.4
* Enhancements
* Wrap raised Ecto exceptions so context is not lost
* Do not override changeset actions
## v4.6.3
* Enhancements
* Add prefix option to check repo status plug
* Bug fix
* Fix map.field notation warning on Elixir 1.17
## v4.6.2
* Bug fix
* Attach directories to Pending Migrations exception
## v4.6.1
* Bug fix
* Ensure "Create database" action is shown when database is not available
## v4.6.0
* Enhancements
* Return 400 for character encoding errors in Postgrex
* Bump Elixir requirement to v1.11+
## v4.5.1
* Bug fix
* Fix a regression on nested `inputs_for`
## v4.5.0
* Enhancements
* Support Phoenix.HTML ~> 4.1
* Use `to_form`'s `:action` as changeset action when passed
## v4.4.3
* Enhancements
* Support Phoenix.HTML ~> 4.0
## v4.4.2
* Enhancements
* Fix warning on undefined migration function when `ecto_sql` is missing
* Support changesets with 3-arity cast function
## v4.4.1
* Enhancements
* Allow migration_lock to be specified in check_repo_status
* Support multiple repos on sandbox plug API
* Support configuring multiple custom migration paths
## v4.4.0
This release bumps the requirement for Ecto and Phoenix.
* Enhancements
* Trap exits when activating the test sandbox
## v4.3.0
* Enhancements
* Support `:phoenix_html` v3.0
## v4.2.1
* Bug fixes
* Only check for storage if we cannot check for migrations. This reduces the amount of operations for successful cases (which are the most common) and avoid issues for when we can't check the storage in the first place
## v4.2.0
* Enhancements
* Support cast_assoc `with` MFA option on inputs_for
* Bug fixes
* Do not treat `InvalidChangesetError` as 422 as those are not logged
* Fix status code in check status exceptions to 503
* Use text for floats and decimals as the `input_type` - numerics have many usability issues that led them to not be widely used
## v4.1.0
* Enhancements
* Add `Phoenix.Ecto.CheckRepoStatus` plug
## v4.0.0
* Enhancements
* Implement `Plug.Status` for `Ecto.StaleEntryError`
* Support Ecto 3.0
## v3.4.0
* Enhancements
* Use `:normal` formatting when converting `Decimal` to HTML safe
* Ignore errors in case `changeset.action` is `:ignore`
* Allow `:timeout` option on external sandbox
* Extract and translate internal exception from `Ecto.SubQueryError`
## v3.3.0
* Enhancements
* Support concurrent and transactional end-to-end tests for external HTTP clients using the new `:at` and `:repo` options to the `Phoenix.Ecto.SQL.Sandbox` plug
## v3.2.3
* Bug fixes
* Make `phoenix_html` dependency optional once again
## v3.2.2
* Enhancements
* Give `Ecto.InvalidChangesetError` plug_status 422
* Bug fixes
* Do not raise for schemaless structs
## v3.2.1
* Bug fixes
* Implement proper input_value/4 callback
## v3.2.0
* Enhancements
* Depend on Phoenix.HTML ~> 2.9
## v3.1.0
* Enhancements
* Depend on Ecto ~> 2.1 and support new `:naive_datetime` and `:utc_datetime` types
## v3.0.1
* Enhancements
* Support non-struct data in changeset
## v3.0.0
* Enhancements
* Add `Phoenix.Ecto.SQL.Sandbox` for concurrent acceptance tests with Phoenix and Ecto based on user-agent
* Use the new sandbox based on user-agent
* Depend on Phoenix.HTML ~> 2.6
* Depend on Ecto ~> 2.0
* Bug fixes
* Do not list errors if changeset has no action
## v2.0.0
* Enhancements
* Depend on Ecto ~> 1.1
* Backwards incompatible changes
* `f.errors` now returns a raw list of `changeset.errors` for the form's changeset which can be further translated with Phoenix' new Gettext support
* No longer implement Poison protocol for `Ecto.Changeset`
## v1.2.0
* Enhancements
* Depend on Ecto ~> 1.0
* Depend on Phoenix.HTML ~> 2.2
* Use the new `:as` option for naming inputs fields instead of `:name`
## v1.1.0
* Enhancements
* Depend on Ecto ~> 0.15
* Support `skip_deleted` in inputs_for
* Support default values from data rather from `:default` option
## v1.0.0
* Enhancements
* Depend on Phoenix.HTML ~> 2.1
* Depend on Ecto ~> 0.15
* Support associations on changesets
## v0.9.0
* Enhancements
* Depend on Phoenix.HTML ~> 2.0
## v0.8.1
* Bug fix
* Ensure we can encode decimals and floats from errors messages
## v0.8.0
* Enhancements
* Depend on Phoenix.HTML ~> 1.4 (includes `input_type` and `input_validation` support)
* Include embeds errors during JSON generation
## v0.7.0
* Enhancements
* Depend on Phoenix.HTML ~> 1.3 (includes `inputs_for` support)
## v0.6.0
* Enhancements
* Depend on Ecto ~> 0.14
## v0.5.0
* Enhancements
* Depend on Ecto ~> 0.12
## v0.4.0
* Enhancements
* Depend on phoenix_html as optional dependency instead of Phoenix
* Depend on poison as optional dependency instead of Phoenix
## v0.3.2
* Bug fix
* Ensure we interpolate `%{count}` in JSON encoding
## v0.3.1
* Enhancements
* Implement Plug.Exception for Ecto exceptions
## v0.3.0
* Enhancements
* Support Phoenix v0.11.0 errors entry in form data
## v0.2.0
* Enhancements
* Implement `Phoenix.HTML.Safe` for `Ecto.Date`, `Ecto.Time` and `Ecto.DateTime`
* Implement `Poison.Encoder` for `Ecto.Changeset`, `Decimal`, `Ecto.Date`, `Ecto.Time` and `Ecto.DateTime`
## v0.1.0
* Enhancements
* Implement `Phoenix.HTML.FormData` for `Ecto.Changeset`
* Implement `Phoenix.HTML.Safe` for `Decimal`

View File

@@ -0,0 +1,20 @@
Copyright (c) 2015 Chris McCord
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,130 @@
# Phoenix/Ecto
[![Action Status](https://github.com/phoenixframework/phoenix_ecto/workflows/CI/badge.svg)](https://github.com/phoenixframework/phoenix_ecto/actions?query=workflow%3ACI)
A project that integrates [Phoenix](http://github.com/phoenixframework/phoenix) with [Ecto](http://github.com/elixir-lang/ecto), implementing all relevant protocols.
## Installation
You can install `phoenix_ecto` by adding it to your list of dependencies in `mix.exs`:
```elixir
def deps do
[{:phoenix_ecto, "~> 4.0"}]
end
```
## The Phoenix <-> Ecto integration
Thanks to Elixir protocols, the integration between Phoenix and Ecto is simply a matter of implementing a handful of protocols. We provide the following implementations:
* `Phoenix.HTML.FormData` protocol for `Ecto.Changeset`
* `Phoenix.HTML.Safe` protocol for `Decimal`
* `Plug.Exception` protocol for the relevant Ecto exceptions
## Concurrent browser tests
This library also provides a plug called `Phoenix.Ecto.SQL.Sandbox` that allows developers to run acceptance tests powered by headless browsers such as ChromeDriver and Selenium concurrently. If you are not familiar with Ecto's SQL sandbox, we recommend you to first get acquainted with it by [reading `Ecto.Adapters.SQL.Sandbox` documentation](https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.Sandbox.html).
To enable concurrent acceptance tests, make sure you are using PostgreSQL and follow the instructions below:
1. Set a flag to enable the sandbox in `config/test.exs`:
```elixir
config :your_app, sql_sandbox: true
```
2. And use the flag to conditionally add the plug to `lib/your_app/endpoint.ex`:
```elixir
if Application.get_env(:your_app, :sql_sandbox) do
plug Phoenix.Ecto.SQL.Sandbox
end
```
Make sure that this is placed **before** the line `plug YourApp.Router` (or any other plug that may access the database).
You can now checkout a sandboxed connection and pass the connection information to an acceptance testing tool like [Hound](https://github.com/hashnuke/hound) or [Wallaby](https://github.com/elixir-wallaby/wallaby).
### Hound
To write concurrent acceptance tests with Hound, first add it as a dependency to your `mix.exs`:
```elixir
{:hound, "~> 1.0"}
```
Make sure to start it at the top of your `test/test_helper.exs`:
```elixir
{:ok, _} = Application.ensure_all_started(:hound)
```
Then add the following to your test case (or case template):
```elixir
use Hound.Helpers
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 = Phoenix.Ecto.SQL.Sandbox.metadata_for(YourApp.Repo, pid)
Hound.start_session(metadata: metadata)
:ok
end
```
Hound supports multiple drivers like Chrome, Firefox, etc but it does not support concurrent tests under PhantomJS (the default).
### Wallaby
To write concurrent acceptance tests with Wallaby, first add it as a dependency to your `mix.exs`:
```elixir
{:wallaby, "~> 0.25", only: :test}
```
Wallaby can take care of setting up the Ecto Sandbox for you if you use `use Wallaby.Feature` in your test module.
```elixir
defmodule MyAppWeb.PageFeature do
use ExUnit.Case, async: true
use Wallaby.Feature
feature "shows some text", %{session: session} do
session
|> visit("/home")
|> assert_text("Hello world!")
end
end
```
If you don't `use Wallaby.Feature`, you can add the following to your test case (or case template):
```elixir
use Wallaby.DSL
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 = Phoenix.Ecto.SQL.Sandbox.metadata_for(YourApp.Repo, pid)
{:ok, session} = Wallaby.start_session(metadata: metadata)
end
```
Wallaby currently supports ChromeDriver and Selenium, allowing testing in almost any browser.
## Configuration
The `Plug.Exception` implementations for Ecto exceptions may be disabled by including the error in the mix configuration.
```elixir
config :phoenix_ecto,
exclude_ecto_exceptions_from_plug: [Ecto.NoResultsError]
```
## Copyright and License
Copyright (c) 2015, Chris McCord.
Phoenix/Ecto source code is licensed under the [MIT License](https://github.com/phoenixframework/phoenix_ecto/blob/master/LICENSE).

View File

@@ -0,0 +1,38 @@
{<<"links">>,
[{<<"GitHub">>,<<"https://github.com/phoenixframework/phoenix_ecto">>}]}.
{<<"name">>,<<"phoenix_ecto">>}.
{<<"version">>,<<"4.7.0">>}.
{<<"description">>,<<"Integration between Phoenix & Ecto">>}.
{<<"elixir">>,<<"~> 1.11">>}.
{<<"files">>,
[<<"lib">>,<<"lib/phoenix_ecto">>,<<"lib/phoenix_ecto/html.ex">>,
<<"lib/phoenix_ecto/check_repo_status.ex">>,<<"lib/phoenix_ecto/plug.ex">>,
<<"lib/phoenix_ecto/exceptions.ex">>,<<"lib/phoenix_ecto/sql">>,
<<"lib/phoenix_ecto/sql/sandbox_session.ex">>,
<<"lib/phoenix_ecto/sql/sandbox.ex">>,<<"lib/phoenix_ecto.ex">>,
<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE">>,
<<"CHANGELOG.md">>]}.
{<<"app">>,<<"phoenix_ecto">>}.
{<<"licenses">>,[<<"MIT">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"phoenix_html">>},
{<<"app">>,<<"phoenix_html">>},
{<<"optional">>,true},
{<<"requirement">>,<<"~> 2.14.2 or ~> 3.0 or ~> 4.1">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"ecto">>},
{<<"app">>,<<"ecto">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 3.5">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"plug">>},
{<<"app">>,<<"plug">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.9">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"postgrex">>},
{<<"app">>,<<"postgrex">>},
{<<"optional">>,true},
{<<"requirement">>,<<"~> 0.16 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

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

View File

@@ -0,0 +1,61 @@
defmodule PhoenixEcto.Mixfile do
use Mix.Project
@source_url "https://github.com/phoenixframework/phoenix_ecto"
@version "4.7.0"
def project do
[
app: :phoenix_ecto,
version: @version,
elixir: "~> 1.11",
deps: deps(),
# Hex
description: "Integration between Phoenix & Ecto",
package: package(),
# Docs
name: "Phoenix/Ecto",
docs: [
main: "main",
extras: ["README.md": [filename: "main", title: "Phoenix/Ecto"]],
source_ref: "v#{@version}",
source_url: @source_url
],
xref: [
exclude: [
{Ecto.Migrator, :migrations, 3},
{Ecto.Migrator, :migrations_path, 1},
{Ecto.Migrator, :run, 4}
]
]
]
end
def application do
[
mod: {Phoenix.Ecto, []},
extra_applications: [:logger],
env: [exclude_ecto_exceptions_from_plug: []]
]
end
defp package do
[
maintainers: ["José Valim", "Chris Mccord"],
licenses: ["MIT"],
links: %{"GitHub" => @source_url}
]
end
defp deps do
[
{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", optional: true},
{:ecto, "~> 3.5"},
{:plug, "~> 1.9"},
{:postgrex, "~> 0.16 or ~> 1.0", optional: true},
{:ex_doc, ">= 0.0.0", only: :docs}
]
end
end