defmodule EctoShorts.CommonChanges do @moduledoc """ `CommonChanges` is a collection of functions to help with managing and creating our `&changeset/2` function in our schemas. ### Preloading associations on change Often times we want to be able to change an association with `(put/cast)_assoc`, but we have an awkwardness of having to use a preload in a spot to do this. We can aleviate that by doing the following: defmodule MyApp.Accounts.User do def changeset(changeset, params) do changeset |> cast([:name, :email]) |> validate_required([:name, :email]) |> EctoShorts.CommonChanges.preload_change_assoc(:address) end end Doing this allows us to then pass address in via a map, or even using the struct from the database directly to add as a relation ### Validating relation is passed in somehow We can validate for a relation being passed in via id or by using our preload_change_assoc by doing the following: defmodule MyApp.Accounts.User do def changeset(changeset, params) do changeset |> cast([:name, :email, :address_id]) |> validate_required([:name, :email]) |> EctoShorts.CommonChanges.preload_change_assoc(:address, required_when_missing: :address_id ) end end ### Conditional functions We can also run functions when something happens by defining conditional functions like so: defmodule MyApp.Accounts.User do alias EctoShorts.CommonChanges def changeset(changeset, params) do changeset |> cast([:name, :email, :address_id]) |> validate_required([:name, :email]) |> CommonChanges.put_when( &CommonChanges.changeset_field_nil?(&1, :email), &put_change(&1, :email, "some_default@gmail.com") ) end end """ require Logger import Ecto.Changeset, only: [ get_field: 2, put_assoc: 4, cast_assoc: 2, cast_assoc: 3 ] alias EctoShorts.{Actions, SchemaHelpers, Config} alias Ecto.Changeset @doc "Run's changeset function if when function returns true" @spec put_when( Changeset.t, ((Changeset.t) -> boolean), ((Changeset.t) -> Changeset.t) ) :: Changeset.t def put_when(changeset, when_func, change_func) do if when_func.(changeset) do change_func.(changeset) else changeset end end @doc "Checks if field on changeset is empty list in data or changes" @spec changeset_field_empty?(Changeset.t, atom) :: boolean def changeset_field_empty?(changeset, key) do get_field(changeset, key) === [] end @doc "Checks if field on changeset is nil in data or changes" @spec changeset_field_nil?(Changeset.t, atom) :: boolean def changeset_field_nil?(changeset, key) do is_nil(get_field(changeset, key)) end @doc """ This function is the primary use function Preloads changeset assoc if change is made and then and put_or_cast's it ## Example iex> CommonChanges.preload_change_assoc(changeset, :my_relation) iex> CommonChanges.preload_change_assoc(changeset, :my_relation, repo: MyApp.OtherRepo) iex> CommonChanges.preload_change_assoc(changeset, :my_relation, required: true) iex> CommonChanges.preload_change_assoc(changeset, :my_relation, required_when_missing: :my_relation_id) """ @spec preload_change_assoc(Changeset.t, atom, keyword()) :: Changeset.t @spec preload_change_assoc(Changeset.t, atom) :: Changeset.t def preload_change_assoc(changeset, key, opts) do required? = if opts[:required_when_missing] do changeset_field_nil?(changeset, opts[:required_when_missing]) else opts[:required] end opts = Keyword.put(opts, :required, required?) if Map.has_key?(changeset.params, Atom.to_string(key)) do changeset |> preload_changeset_assoc(key, opts) |> put_or_cast_assoc(key, opts) else cast_assoc(changeset, key, opts) end end def preload_change_assoc(changeset, key) do if Map.has_key?(changeset.params, Atom.to_string(key)) do changeset |> preload_changeset_assoc(key) |> put_or_cast_assoc(key) else cast_assoc(changeset, key) end end @doc "Preloads a changesets association" @spec preload_changeset_assoc(Changeset.t, atom) :: Changeset.t @spec preload_changeset_assoc(Changeset.t, atom, keyword()) :: Changeset.t def preload_changeset_assoc(changeset, key, opts \\ []) def preload_changeset_assoc(changeset, key, opts) do opts = Keyword.merge(default_opts(), opts) if opts[:ids] do schema = changeset_relationship_schema(changeset, key) preloaded_data = Actions.all(schema, %{ids: opts[:ids]}, repo: opts[:repo]) Map.update!(changeset, :data, &Map.put(&1, key, preloaded_data)) else Map.update!(changeset, :data, &opts[:repo].preload(&1, key, opts)) end end defp changeset_relationship_schema(changeset, key) do if Map.has_key?(changeset.types, key) and relationship_exists?(changeset.types[key]) do changeset.types |> Map.get(key) |> elem(1) |> Map.get(:queryable) else Logger.warning("Changeset relationship for CommonChanges.put_or_cast_assoc #{key} was not found") changeset end end @doc """ Determines put or cast on association with some special magic If you pass a many to many relation only a list of id's it will count that as a `member_update` and remove or add members to the relations list E.G. User many_to_many Fruit This would update the user to have only fruits with id 1 and 3 ```elixir CommonChanges.put_or_cast_assoc(change(user, fruits: [%{id: 1}, %{id: 3}]), :fruits) ``` """ @spec put_or_cast_assoc(Changeset.t, atom) :: Changeset.t @spec put_or_cast_assoc(Changeset.t, atom, Keyword.t) :: Changeset.t def put_or_cast_assoc(changeset, key, opts \\ []) do params_data = Map.get(changeset.params, Atom.to_string(key)) find_method_and_put_or_cast(changeset, key, params_data, opts) end defp find_method_and_put_or_cast(changeset, key, nil, opts) do cast_assoc(changeset, key, opts) end defp find_method_and_put_or_cast(changeset, key, params_data, opts) when is_list(params_data) do cond do SchemaHelpers.all_schemas?(params_data) -> put_assoc( changeset, key, params_data, opts ) member_update?(params_data) -> schema = changeset_relationship_schema(changeset, key) data = Actions.all(schema, ids: data_ids(params_data)) put_assoc(changeset, key, data, opts) SchemaHelpers.any_created?(params_data) -> changeset |> preload_changeset_assoc( key, Keyword.put(opts, :ids, params_data |> data_ids |> Enum.reject(&is_nil/1)) ) |> cast_assoc(key, opts) true -> cast_assoc(changeset, key, opts) end end defp find_method_and_put_or_cast(changeset, key, param_data, opts) do if SchemaHelpers.schema?(param_data) do put_assoc(changeset, key, param_data, opts) else cast_assoc(changeset, key, opts) end end defp member_update?(schemas) do Enum.all?(schemas, fn %{id: id} = item when item === %{id: id} -> true _ -> false end) end defp data_ids(data), do: Enum.map(data, &Map.get(&1, :id)) defp relationship_exists?({:assoc, _}), do: true defp relationship_exists?(_), do: false def default_opts, do: [repo: Config.repo()] end