defmodule Params do @moduledoc ~S""" Functions for processing params and transforming their changesets. `use Params` provides a `defparams` macro, allowing you to define functions that process parameters according to some [schema](Params.Schema.html) ## Example ```elixir defmodule MyApp.SessionController do use Params defparams login_params(%{email!: :string, :password!: :string}) def create(conn, params) do case login_params(params) do %Ecto.Changeset{valid?: true} = ch -> login = Params.data(ch) User.authenticate(login.email, login.password) # ... _ -> text(conn, "Invalid parameters") end end end ``` """ @relations [:embed, :assoc] alias Ecto.Changeset @doc false defmacro __using__([]) do quote do import Params.Def, only: [defparams: 1, defparams: 2] end end @doc """ Transforms an Ecto.Changeset into a Map with atom keys. Recursively traverses and transforms embedded changesets and skips keys that was not part of params given to changeset """ @spec to_map(Changeset.t) :: map def to_map(%Changeset{data: %{__struct__: module}} = ch) do default = module |> schema |> defaults change = changes(ch) deep_merge(default, change) end @doc """ Transforms an Ecto.Changeset into a struct. Recursively traverses and transforms embedded changesets. For example if the `LoginParams` module was defined like: ```elixir defmodule LoginParams do use Params.Schema, %{login!: :string, password!: :string} end ``` You can transform the changeset returned by `from` into an struct like: ```elixir data = LoginParams.from(%{"login" => "foo"}) |> Params.data data.login # => "foo" ``` """ @spec data(Changeset.t) :: Struct.t def data(%Changeset{data: data = %{__struct__: module}} = ch) do default_embeds = default_embeds_from_schema(module) default = Enum.reduce(default_embeds, data, fn {k, v}, m -> Map.put(m, k, Map.get(m, k) || v) end) Enum.reduce(ch.changes, default, fn {k, v}, m -> case v do %Changeset{} -> Map.put(m, k, data(v)) x = [%Changeset{} | _] -> Map.put(m, k, Enum.map(x, &data/1)) _ -> Map.put(m, k, v) end end) end @doc false def default_embeds_from_schema(module) when is_atom(module) do is_embed_default = fn kw -> Keyword.get(kw, :embeds, []) |> Enum.any?(&Keyword.has_key?(&1, :default)) end default_embed = fn kw -> name = Keyword.get(kw, :name) embed_name = Params.Def.module_concat(module, name) {name, default_embeds_from_schema(embed_name)} end case schema(module) do nil -> %{} schema -> schema |> Stream.filter_map(is_embed_default, default_embed) |> Enum.into(struct(module) |> Map.from_struct) end end @doc false def schema(module) when is_atom(module) do module.__info__(:attributes) |> Keyword.get(:schema) end @doc false def required(module) when is_atom(module) do module.__info__(:attributes) |> Keyword.get(:required, []) end @doc false def optional(module) when is_atom(module) do module.__info__(:attributes) |> Keyword.get(:optional) |> case do nil -> module.__changeset__ |> Map.keys x -> x end end @doc false def changeset(%Changeset{data: %{__struct__: module}} = changeset, params) do {required, required_relations} = relation_partition(module, required(module)) {optional, optional_relations} = relation_partition(module, optional(module)) changeset |> Changeset.cast(params, required ++ optional) |> Changeset.validate_required(required) |> cast_relations(required_relations, [required: true]) |> cast_relations(optional_relations, []) end @doc false def changeset(model = %{__struct__: _}, params) do changeset(model |> change, params) end @doc false def changeset(module, params) when is_atom(module) do changeset(module |> change, params) end defp change(%{__struct__: _} = model) do model |> Changeset.change end defp change(module) when is_atom(module) do module |> struct |> Changeset.change end defp relation_partition(module, names) do types = module.__changeset__ names |> Stream.map(fn x -> String.to_atom("#{x}") end) |> Enum.reduce({[], []}, fn name, {fields, relations} -> case Map.get(types, name) do {type, _} when type in @relations -> {fields, [{name, type} | relations]} _ -> {[name | fields], relations} end end) end defp cast_relations(changeset, relations, opts) do Enum.reduce(relations, changeset, fn {name, :assoc}, ch -> Changeset.cast_assoc(ch, name, opts) {name, :embed}, ch -> Changeset.cast_embed(ch, name, opts) end) end defp deep_merge(%{} = map_1, %{} = map_2) do Map.merge(map_1, map_2, &deep_merge_conflict/3) end defp deep_merge_conflict(_k, %{} = m1, %{} = m2) do deep_merge(m1, m2) end defp deep_merge_conflict(_k, _v1, v2), do: v2 defp defaults(params, acc \\ %{}, path \\ []) defp defaults([], acc, _path), do: acc defp defaults(nil, _acc, _path), do: %{} defp defaults([opts | rest], acc, path) when is_list(opts) do defaults([Enum.into(opts, %{}) | rest], acc, path) end defp defaults([%{name: name, embeds: embeds} | rest], acc, path) do acc = defaults(embeds, acc, [name | path]) defaults(rest, acc, path) end defp defaults([%{name: name, default: value} | rest], acc, path) do funs = [name | path] |> Enum.reverse |> Enum.map(fn nested_name -> fn :get_and_update, data, next -> with {nil, inner_data} <- next.(data[nested_name] || %{}), data = Map.put(data, nested_name, inner_data), do: {nil, data} end end) acc = put_in(acc, funs, value) defaults(rest, acc, path) end defp defaults([%{} | rest], acc, path) do defaults(rest, acc, path) end defp changes(%Changeset{} = ch) do Enum.reduce(ch.changes, %{}, fn {k, v}, m -> case v do %Changeset{} -> Map.put(m, k, changes(v)) x = [%Changeset{} | _] -> Map.put(m, k, Enum.map(x, &changes/1)) _ -> Map.put(m, k, v) end end) end end