defmodule Ash.Changeset do @moduledoc """ Changesets are used to create and update data in Ash. Create a changeset with `new/1` or `new/2`, and alter the attributes and relationships using the functions provided in this module. Nothing in this module actually incurs changes in a data layer. To commit a changeset, see `c:Ash.Api.create/2` and `c:Ash.Api.update/2`. For example: ```elixir Ash.Changeset.replace_relationship(changeset, :linked_tickets, [ {1, %{link_type: "blocking"}}, {a_ticket, %{link_type: "caused_by"}}, {%{id: 2}, %{link_type: "related_to"}} ]) ``` `Ash.Changeset.replace_relationship/3`, `Ash.Changeset.append_to_relationship/3` and `Ash.Changeset.remove_from_relationship/3` are simply about managing what data is/isn't related. A simple example might be updating the *tags* of a post, where all the tags already exist, we simply want to edit the information. They are shorthands for calling `Ash.Changeset.manage_relationship/4` with a specific set of options. See the action DSL documentation for more. """ defstruct [ :data, :action_type, :action, :resource, :api, :tenant, :__validated_for_action__, select: nil, params: %{}, action_failed?: false, arguments: %{}, context: %{}, after_action: [], before_action: [], errors: [], valid?: true, attributes: %{}, relationships: %{}, change_dependencies: [] ] defimpl Inspect do import Inspect.Algebra def inspect(changeset, opts) do context = Map.delete(changeset.context, :private) context = if context == %{} do empty() else concat("context: ", to_doc(context, opts)) end tenant = if changeset.tenant do concat("tenant: ", to_doc(changeset.tenant, opts)) else empty() end container_doc( "#Ash.Changeset<", [ concat("action_type: ", inspect(changeset.action_type)), concat("action: ", inspect(changeset.action && changeset.action.name)), tenant, concat("attributes: ", to_doc(changeset.attributes, opts)), concat("relationships: ", to_doc(changeset.relationships, opts)), arguments(changeset, opts), concat("errors: ", to_doc(changeset.errors, opts)), concat("data: ", to_doc(changeset.data, opts)), context, concat("valid?: ", to_doc(changeset.valid?, opts)) ], ">", opts, fn str, _ -> str end ) end defp arguments(changeset, opts) do if changeset.action do if Enum.empty?(changeset.action.arguments) do empty() else arg_string = changeset.action.arguments |> Enum.filter(fn argument -> match?({:ok, _}, Ash.Changeset.fetch_argument(changeset, argument.name)) end) |> Map.new(fn argument -> if argument.sensitive? do {argument.name, "**redacted**"} else {argument.name, Ash.Changeset.get_argument(changeset, argument.name)} end end) |> to_doc(opts) concat(["arguments: ", arg_string]) end else empty() end end end @type t :: %__MODULE__{} alias Ash.Error.{ Changes.InvalidArgument, Changes.InvalidAttribute, Changes.InvalidChanges, Changes.InvalidRelationship, Changes.NoSuchAttribute, Changes.NoSuchRelationship, Changes.Required, Invalid.NoSuchAction, Invalid.NoSuchResource } @doc """ Return a changeset over a resource or a record. `params` can be either attributes, relationship values or arguments. If you are using external input, you almost certainly want to use `Ash.Changeset.for_`. However, you can use `Ash.Changeset.new/2` to start a changeset and make a few changes prior to calling `for_action`. For example: ```elixir Ash.Changeset.new() |> Ash.Changeset.change_attribute(:name, "foobar") |> Ash.Changeset.for_action(...) ``` Anything that is modified prior to `for_action` is validated against the rules of the action, while *anything after it is not*. This changeset does not consider an action, and so allows you to change things with minimal validation. Values are validated when changed, and the existence of attributes and relationships are validated. If you want to essentially "run an action", and get back a changeset with any errors that would be generated by that action (with the exception of errors that can only be generated by the data layer), use `for_action/2`. Additionally, this format only supports supplying attributes in the params. This is because we don't know what the behavior should be for relationship changes, nor what arguments are available. You can manage them yourself with the functions that allow managing arguments/relationships that are provided in this module, e.g `set_argument/3` and `replace_relationship/3` """ @spec new(Ash.Resource.t() | Ash.Resource.record(), params :: map) :: t def new(resource, params \\ %{}) def new(%resource{} = record, params) do tenant = record |> Map.get(:__metadata__, %{}) |> Map.get(:tenant, nil) context = Ash.Resource.Info.default_context(resource) || %{} if Ash.Resource.Info.resource?(resource) do %__MODULE__{resource: resource, data: record, action_type: :update} |> change_attributes(params) |> set_context(context) |> set_tenant(tenant) else %__MODULE__{ resource: resource, action_type: :update, data: struct(resource) } |> add_error(NoSuchResource.exception(resource: resource)) |> set_tenant(tenant) |> set_context(context) end end def new(resource, params) do if Ash.Resource.Info.resource?(resource) do %__MODULE__{ resource: resource, action_type: :create, data: struct(resource) } |> change_attributes(params) else %__MODULE__{resource: resource, action_type: :create, data: struct(resource)} |> add_error(NoSuchResource.exception(resource: resource)) end end @doc """ Ensure that only the specified attributes are present in the results. The first call to `select/2` will replace the default behavior of selecting all attributes. Subsequent calls to `select/2` will combine the provided fields unless the `replace?` option is provided with a value of `true`. If a field has been deselected, selecting it again will override that (because a single list of fields is tracked for selection) Primary key and private attributes are always selected and cannot be deselected. When attempting to load a relationship (or manage it with `Ash.Changeset.manage_relationship/3`), if the source field is not selected on the query/provided data an error will be produced. If loading a relationship with a query, an error is produced if the query does not select the destination field of the relationship. """ def select(changeset, fields, opts \\ []) do if opts[:replace?] do %{changeset | select: Enum.uniq(List.wrap(fields))} else %{changeset | select: Enum.uniq(List.wrap(fields) ++ (changeset.select || []))} end end @doc """ Ensure the the specified attributes are `nil` in the changeset results. """ def deselect(changeset, fields) do select = if changeset.select do changeset.select else changeset.resource |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name) end select = select -- List.wrap(fields) select(changeset, select, replace?: true) end def selecting?(changeset, field) do case changeset.select do nil -> not is_nil(Ash.Resource.Info.attribute(changeset.resource, field)) select -> if field in select do true else attribute = Ash.Resource.Info.attribute(changeset.resource, field) attribute && (attribute.private? || attribute.primary_key?) end end end @for_create_opts [ relationships: [ type: :any, doc: """ customize relationship behavior. By default, any relationships are *replaced* via `replace_relationship`. To change this behavior, provide the `relationships` option. The values for each relationship can be * `:append` - passes input to `append_to_relatinship/3` * `:remove` - passes input to `remove_from_relationship/3` * `:replace` - passes input to `replace_relationship/3` * `{:manage, opts}` - passes the input to `manage_relationship/4`, with the given `opts`. For example: ```elixir Ash.Changeset.for_create(MyResource, :create, params, relationships: [relationship: :append, other_relationship: :remove]) ``` """ ], actor: [ type: :any, doc: "set the actor, which can be used in any `Ash.Resource.Change`s configured on the action. (in the `context` argument)" ], tenant: [ type: :any, doc: "set the tenant on the changeset" ] ] @doc false def for_create_opts, do: @for_create_opts @doc """ Constructs a changeset for a given create action, and validates it. Anything that is modified prior to `for_create/4` is validated against the rules of the action, while *anything after it is not*. Multitenancy is *not* validated until an action is called. This allows you to avoid specifying a tenant until just before calling the api action. ### Params `params` may be attributes, relationships, or arguments. You can safely pass user/form input directly into this function. Only public attributes and relationships are supported. If you want to change private attributes as well, see the Customization section below. `params` are stored directly as given in the `params` field of the changeset, which is used ### Opts #{Ash.OptionsHelpers.docs(@for_create_opts)} ### Customization A changeset can be provided as the first argument, instead of a resource, to allow setting specific attributes ahead of time. For example: MyResource |> Changeset.change_attribute(:foo, 1) |> Changeset.for_create(:create, ...opts) Once a changeset has been validated by `for_create/4` (or `for_update/4`), it isn't validated again in the action. New changes added are validated individually, though. This allows you to create a changeset according to a given action, and then add custom changes if necessary. """ def for_create(initial, action, params \\ %{}, opts \\ []) do changeset = case initial do %__MODULE__{action_type: :create} = changeset -> changeset %__MODULE__{} = changeset -> add_error( changeset, "Initial changeset provided with invalid action type: #{changeset.action_type}" ) resource when is_atom(resource) -> new(resource) other -> %__MODULE__{resource: other, action_type: :create} |> add_error(NoSuchResource.exception(resource: other)) end for_action(changeset, action, params, opts) end @doc """ Constructs a changeset for a given update action, and validates it. Anything that is modified prior to `for_update/4` is validated against the rules of the action, while *anything after it is not*. See `for_create/4` for more information """ def for_update(initial, action, params \\ %{}, opts \\ []) do changeset = case initial do # We accept :destroy here to support soft deletes %__MODULE__{action_type: type} = changeset when type in [:update, :destroy] -> changeset %__MODULE__{} = changeset -> add_error( changeset, "Initial changeset provided with invalid action type: #{changeset.action_type}" ) %_{} = struct -> new(struct) _other -> %__MODULE__{resource: nil, action_type: :update} |> add_error(NoSuchResource.exception(resource: nil)) end for_action(changeset, action, params, opts) end @doc """ Constructs a changeset for a given destroy action, and validates it. ### Opts * `:actor` - set the actor, which can be used in any `Ash.Resource.Change`s configured on the action. (in the `context` argument) * `:defaults` - A list of attributes and arguments to apply defaults for. Defaults to: []. Any unset defaults are set when the action is called. * `:tenant` - set the tenant on the changeset Anything that is modified prior to `for_destroy/4` is validated against the rules of the action, while *anything after it is not*. Once a changeset has been validated by `for_destroy/4`, it isn't validated again in the action. New changes added are validated individually, though. This allows you to create a changeset according to a given action, and then add custom changes if necessary. """ def for_destroy(initial, action_name, params \\ %{}, opts \\ []) do changeset = case initial do %__MODULE__{} = changeset -> changeset |> Map.put(:action_type, :destroy) %_{} = struct -> struct |> new() |> Map.put(:action_type, :destroy) _other -> %__MODULE__{resource: nil, action_type: :destroy} |> add_error(NoSuchResource.exception(resource: nil)) end if changeset.valid? do action = Ash.Resource.Info.action(changeset.resource, action_name, changeset.action_type) if action do changeset |> set_tenant(opts[:tenant] || changeset.tenant) |> Map.put(:__validated_for_action__, action.name) |> Map.put(:action, action) |> cast_params(action, params, opts) |> cast_arguments(action, opts[:defaults], true) |> add_validations() |> mark_validated(action.name) else add_error( changeset, NoSuchAction.exception( resource: changeset.resource, action: action_name, type: :destroy ) ) end else changeset end end defp for_action(changeset, action, params, opts) do if changeset.valid? do action = Ash.Resource.Info.action(changeset.resource, action, changeset.action_type) if action do changeset |> set_tenant(opts[:tenant] || changeset.tenant) |> Map.put(:action, action) |> Map.put(:__validated_for_action__, action.name) |> cast_params(action, params || %{}, opts) |> validate_attributes_accepted(action) |> validate_relationships_accepted(action) |> cast_arguments(action, opts[:defaults], true) |> run_action_changes(action, opts[:actor]) |> set_defaults(changeset.action_type, false) |> add_validations() |> require_values(changeset.action_type) |> mark_validated(action.name) else add_error( changeset, NoSuchAction.exception( resource: changeset.resource, action: action, type: changeset.action_type ) ) end else changeset end end defp mark_validated(changeset, action_name) do %{changeset | __validated_for_action__: action_name} end @doc false def validate_multitenancy(changeset) do if Ash.Resource.Info.multitenancy_strategy(changeset.resource) && not Ash.Resource.Info.multitenancy_global?(changeset.resource) && is_nil(changeset.tenant) do add_error( changeset, "#{inspect(changeset.resource)} changesets require a tenant to be specified" ) else changeset end end defp cast_params(changeset, action, params, opts) do changeset = %{ changeset | params: Map.merge(changeset.params || %{}, Enum.into(params || %{}, %{})) } Enum.reduce(params, changeset, fn {name, value}, changeset -> cond do has_argument?(action, name) -> set_argument(changeset, name, value) attr = Ash.Resource.Info.public_attribute(changeset.resource, name) -> if attr.writable? do change_attribute(changeset, attr.name, value) else changeset end rel = Ash.Resource.Info.public_relationship(changeset.resource, name) -> if rel.writable? do behaviour = opts[:relationships][rel.name] || :replace case behaviour do :replace -> replace_relationship(changeset, rel.name, value) :append -> append_to_relationship(changeset, rel.name, value) :remove -> remove_from_relationship(changeset, rel.name, value) {:manage, opts} -> manage_relationship(changeset, rel.name, value, opts) end else changeset end true -> changeset end end) end defp has_argument?(action, name) when is_atom(name) do Enum.any?(action.arguments, &(&1.private? == false && &1.name == name)) end defp has_argument?(action, name) when is_binary(name) do Enum.any?(action.arguments, &(&1.private? == false && to_string(&1.name) == name)) end defp validate_attributes_accepted(changeset, %{accept: nil}), do: changeset defp validate_attributes_accepted(changeset, %{accept: accepted_attributes}) do changeset.attributes |> Enum.reject(fn {key, _value} -> key in accepted_attributes end) |> Enum.reduce(changeset, fn {key, _}, changeset -> add_error( changeset, InvalidAttribute.exception(field: key, message: "cannot be changed") ) end) end defp validate_relationships_accepted(changeset, %{accept: nil}), do: changeset defp validate_relationships_accepted(changeset, %{accept: accepted_relationships}) do changeset.relationships |> Enum.reject(fn {key, _value} -> key in accepted_relationships end) |> Enum.reduce(changeset, fn {key, _}, changeset -> add_error( changeset, InvalidRelationship.exception( relationship: key, message: "Cannot be changed" ) ) end) end defp run_action_changes(changeset, %{changes: changes}, actor) do Enum.reduce(changes, changeset, fn %{change: {module, opts}}, changeset -> module.change(changeset, opts, %{actor: actor}) %{validation: _} = validation, changeset -> if validation.expensive? and not changeset.valid? do changeset else do_validation(changeset, validation) end end) end @doc false def set_defaults(changeset, action_type, lazy? \\ false) def set_defaults(changeset, :create, lazy?) do changeset.resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(not is_nil(&1.default))) |> Enum.filter(fn attribute -> lazy? or not (is_function(attribute.default) or match?({_, _, _}, attribute.default)) end) |> Enum.reduce(changeset, fn attribute, changeset -> force_change_new_attribute_lazy(changeset, attribute.name, fn -> default(:create, attribute) end) end) end def set_defaults(changeset, :update, lazy?) do changeset.resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(not is_nil(&1.update_default))) |> Enum.filter(fn attribute -> lazy? or not (is_function(attribute.update_default) or match?({_, _, _}, attribute.update_default)) end) |> Enum.reduce(changeset, fn attribute, changeset -> force_change_new_attribute_lazy(changeset, attribute.name, fn -> default(:update, attribute) end) end) end def set_defaults(changeset, _, _) do changeset end defp default(:create, %{default: {mod, func, args}}), do: apply(mod, func, args) defp default(:create, %{default: function}) when is_function(function, 0), do: function.() defp default(:create, %{default: value}), do: value defp default(:update, %{update_default: {mod, func, args}}), do: apply(mod, func, args) defp default(:update, %{update_default: function}) when is_function(function, 0), do: function.() defp default(:update, %{update_default: value}), do: value defp add_validations(changeset) do Ash.Changeset.before_action(changeset, fn changeset -> changeset.resource # We use the `changeset.action_type` to support soft deletes # Because a delete is an `update` with an action type of `update` |> Ash.Resource.Info.validations(changeset.action_type) |> Enum.reduce(changeset, fn validation, changeset -> if validation.expensive? and not changeset.valid? do changeset else do_validation(changeset, validation) end end) end) end defp do_validation(changeset, validation) do case validation.module.validate(changeset, validation.opts) do :ok -> changeset {:error, error} when is_binary(error) -> Ash.Changeset.add_error(changeset, validation.message || error) {:error, error} when is_exception(error) -> if validation.message do error = case error do %{field: field} when not is_nil(field) -> error |> Map.take([:field, :vars]) |> Map.to_list() |> Keyword.put(:message, validation.message) |> InvalidAttribute.exception() %{fields: fields} when fields not in [nil, []] -> error |> Map.take([:fields, :vars]) |> Map.to_list() |> Keyword.put(:message, validation.message) |> InvalidChanges.exception() _ -> validation.message end Ash.Changeset.add_error(changeset, error) else Ash.Changeset.add_error(changeset, error) end {:error, error} -> error = if Keyword.keyword?(error) do Keyword.put(error, :message, validation.message || error[:message]) else validation.message || error end Ash.Changeset.add_error(changeset, error) end end @doc false def require_values(changeset, action_type, private? \\ false) def require_values(changeset, :create, private?) do attributes = if private? do changeset.resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(!&1.allow_nil? && &1.private? && !&1.generated?)) else changeset.resource |> Ash.Resource.Info.attributes() |> Enum.reject(&(&1.allow_nil? || &1.private? || &1.generated?)) end Enum.reduce(attributes, changeset, fn required_attribute, changeset -> if Ash.Changeset.changing_attribute?(changeset, required_attribute.name) || not is_nil(required_attribute.default) do changeset else Ash.Changeset.add_error( changeset, Required.exception(field: required_attribute.name, type: :attribute) ) end end) end def require_values(changeset, :update, private?) do attributes = if private? do changeset.resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(!&1.allow_nil? && &1.private? && !&1.generated?)) else changeset.resource |> Ash.Resource.Info.attributes() |> Enum.reject(&(&1.allow_nil? || &1.private? || &1.generated?)) end Enum.reduce(attributes, changeset, fn required_attribute, changeset -> if Ash.Changeset.changing_attribute?(changeset, required_attribute.name) do if is_nil(get_attribute(changeset, required_attribute.name)) do Ash.Changeset.add_error( changeset, Required.exception(field: required_attribute.name, type: :attribute) ) else changeset end else changeset end end) end def require_values(changeset, _, _), do: changeset @doc """ Wraps a function in the before/after action hooks of a changeset. The function takes a changeset and if it returns `{:ok, result}`, the result will be passed through the after action hooks. """ @spec with_hooks( t(), (t() -> {:ok, Ash.Resource.record(), %{notifications: list(Ash.Notifier.Notification.t())}} | {:error, term}) ) :: {:ok, term, t(), %{notifications: list(Ash.Notifier.Notification.t())}} | {:error, term} def with_hooks(%{valid?: false} = changeset, _func) do {:error, changeset.errors} end def with_hooks(changeset, func) do {changeset, %{notifications: before_action_notifications}} = Enum.reduce_while( changeset.before_action, {changeset, %{notifications: []}}, fn before_action, {changeset, instructions} -> case before_action.(changeset) do {changeset, %{notifications: notifications}} -> cont = if changeset.valid? do :cont else :halt end {cont, {changeset, %{ instructions | notifications: instructions.notifications ++ List.wrap(notifications) }}} changeset -> cont = if changeset.valid? do :cont else :halt end {cont, {changeset, instructions}} end end ) if changeset.valid? do case func.(changeset) do {:ok, result} -> run_after_actions(result, changeset, before_action_notifications) {:error, error} -> {:error, error} end else {:error, changeset.errors} end end defp run_after_actions(result, changeset, before_action_notifications) do Enum.reduce_while( changeset.after_action, {:ok, result, changeset, %{notifications: before_action_notifications}}, fn after_action, {:ok, result, changeset, %{notifications: notifications} = acc} -> case after_action.(changeset, result) do {:ok, new_result, new_notifications} -> all_notifications = Enum.map(notifications ++ new_notifications, fn notification -> %{ notification | resource: notification.resource || changeset.resource, action: notification.action || Ash.Resource.Info.action( changeset.resource, changeset.action, changeset.action_type ), data: notification.data || new_result, changeset: notification.changeset || changeset, actor: notification.actor || changeset.context[:private][:actor] } end) {:cont, {:ok, new_result, changeset, %{acc | notifications: all_notifications}}} {:ok, new_result} -> {:cont, {:ok, new_result, changeset, acc}} {:error, error} -> {:halt, {:error, error}} end end ) end @doc "Gets the value of an argument provided to the changeset" @spec get_argument(t, atom) :: term def get_argument(changeset, argument) when is_atom(argument) do Map.get(changeset.arguments, argument) || Map.get(changeset.arguments, to_string(argument)) end @doc "fetches the value of an argument provided to the changeset or `:error`" @spec fetch_argument(t, atom) :: {:ok, term} | :error def fetch_argument(changeset, argument) when is_atom(argument) do case Map.fetch(changeset.arguments, argument) do {:ok, value} -> {:ok, value} :error -> case Map.fetch(changeset.arguments, to_string(argument)) do {:ok, value} -> {:ok, value} :error -> :error end end end @doc "Gets the changing value or the original value of an attribute" @spec get_attribute(t, atom) :: term def get_attribute(changeset, attribute) do case fetch_change(changeset, attribute) do {:ok, value} -> value :error -> get_data(changeset, attribute) end end @doc "Gets the new value for an attribute, or `:error` if it is not being changed" @spec fetch_change(t, atom) :: {:ok, any} | :error def fetch_change(changeset, attribute) do Map.fetch(changeset.attributes, attribute) end @doc "Gets the original value for an attribute" @spec get_data(t, atom) :: {:ok, any} | :error def get_data(changeset, attribute) do Map.get(changeset.data, attribute) end @doc """ Puts a key/value in the changeset context that can be used later Do not use the `private` key in your custom context, as that is reserved for internal use. """ @spec put_context(t(), atom, term) :: t() def put_context(changeset, key, value) do set_context(changeset, %{key => value}) end @spec set_tenant(t(), String.t()) :: t() def set_tenant(changeset, tenant) do %{changeset | tenant: tenant} end @doc """ Deep merges the provided map into the changeset context that can be used later Do not use the `private` key in your custom context, as that is reserved for internal use. """ @spec set_context(t(), map | nil) :: t() def set_context(changeset, nil), do: changeset def set_context(changeset, map) do %{changeset | context: Ash.Helpers.deep_merge_maps(changeset.context, map)} end @doc false def cast_arguments(changeset, action, defaults \\ :all, only_supplied? \\ false) do action.arguments |> Enum.reject(& &1.private?) |> Enum.filter(fn argument -> if only_supplied? do match?({:ok, _}, fetch_argument(changeset, argument.name)) else true end end) |> Enum.reduce(changeset, fn argument, new_changeset -> {ignore_nil_check, value} = if defaults == :all || argument.name in (defaults || []) do {false, get_argument(changeset, argument.name) || argument_default(argument.default)} else value = get_argument(changeset, argument.name) if argument_default(argument.default) do {true, value} else {false, value} end end cond do !ignore_nil_check && is_nil(value) && !argument.allow_nil? -> Ash.Changeset.add_error( changeset, Required.exception(field: argument.name, type: :argument) ) is_nil(value) -> changeset true -> val = case fetch_argument(changeset, argument.name) do :error -> if argument.default do {:ok, argument_default(argument.default)} else :error end {:ok, val} -> {:ok, val} end with {:found, {:ok, value}} <- {:found, val}, {:ok, casted} <- Ash.Type.cast_input(argument.type, value), {:ok, casted} <- Ash.Type.apply_constraints(argument.type, casted, argument.constraints) do if casted do %{ new_changeset | arguments: Map.put(new_changeset.arguments, argument.name, casted) } else case argument_default(argument.default) do nil -> changeset value -> %{ new_changeset | arguments: Map.put(new_changeset.arguments, argument.name, value) } end end else {:error, error} -> add_invalid_errors(:argument, changeset, argument, error) {:found, :error} -> changeset end end end) end defp argument_default(value) when is_function(value, 0), do: value.() defp argument_default(value), do: value @manage_opts [ authorize?: [ type: :boolean, default: false, doc: "Authorize reads and changes to the destination records, if the primary change is being authorized as well." ], on_no_match: [ type: :any, default: :ignore, doc: """ instructions for handling records where no matching record existed in the relationship * `:ignore`(default) - those inputs are ignored * `:create` - the records are created using the destination's primary create action * `{:create, :action_name}` - the records are created using the specified action on the destination resource * `{:create, :action_name, :join_table_action_name, [:list, :of, :join_table, :params]}` - Same as `{:update, :action_name}` but takes the list of params specified out and applies them when creating the join table row. * `:error` - an eror is returned indicating that a record would have been created * If `on_lookup` is set, and the data contained a primary key or identity, then the error is a `NotFound` error * Otherwise, an `InvalidRelationship` error is returned """ ], on_lookup: [ type: :any, default: :ignore, doc: """ Before creating a record(because no match was found in the relationship), the record can be looked up and related. * `:ignore`(default) - Does not look for existing entries (matches in the current relationship are still considered updates) * `:relate` - Same as calling `{:relate, primary_action_name}` * `{:relate, :action_name}` - the records are looked up by primary key/the first identity that is found (using the primary read action), and related. The action should be: * many_to_many - a create action on the join resource * has_many - an update action on the destination resource * has_one - an update action on the destination resource * belongs_to - an update action on the source resource * `{:relate, :action_name, :read_action_name}` - Same as the above, but customizes the read action called to search for matches. * `:relate_and_update` - Same as calling `{:relate_and_update, primary_action_name}`, but the remaining parameters are used with the `on_match` option * `{:relate_and_update, :action_name}` - the records are looked up by primary key/the first identity that is found (using the primary read action), and related. Then the `update` instructions are followed. The action should be: * many_to_many - a create action on the join resource * has_many - an update action on the destination resource * has_one - an update action on the destination resource * belongs_to - an update action on the source resource * `{:relate_and_update, :action_name, :read_action_name}` - Same as the above, but customizes the read action called to search for matches. * `{:relate_and_update, :action_name, :read_action_name, [:list, :of, :join_table, :params]}` - Same as the above, but uses the provided list of parameters when creating the join row (only relevant for many to many relationships) """ ], on_match: [ type: :any, default: :ignore, doc: """ instructions for handling records where a matching record existed in the relationship already * `:ignore`(default) - those inputs are ignored * `:update` - the record is updated using the destination's primary update action * `{:update, :action_name}` - the record is updated using the specified action on the destination resource * `{:update, :action_name, :join_table_action_name, [:list, :of, :params]}` - Same as `{:update, :action_name}` but takes the list of params specified out and applies them as an update to the join table row (only valid for many to many). * `:error` - an eror is returned indicating that a record would have been updated * `:create` - ignores the primary key match and follows the create instructions with these records instead. * `:destroy` - follows the destroy instructions for any records with matching primary keys * `:unrelate` - the related item is not destroyed, but the data is "unrelated", making this behave like `remove_from_relationship/3`. The action should be: * many_to_many - the join resource row is destroyed * has_many - the destination_field (on the related record) is set to `nil` * has_one - the destination_field (on the related record) is set to `nil` * belongs_to - the source_field (on this record) is set to `nil` * `{:unrelate, :action_name}` - the record is unrelated using the provided update action. The action should be: * many_to_many - a destroy action on the join resource * has_many - an update action on the destination resource * has_one - an update action on the destination resource * belongs_to - an update action on the source resource """ ], on_missing: [ type: :any, default: :ignore, doc: """ instructions for handling records that existed in the current relationship but not in the input * `:ignore`(default) - those inputs are ignored * `:destroy` - the record is destroyed using the destination's primary destroy action * `{:destroy, :action_name}` - the record is destroyed using the specified action on the destination resource * `{:destroy, :action_name, :join_resource_action_name}` - the record is destroyed using the specified action on the destination resource, but first the join resource is destroyed with its specified action * `:error` - an error is returned indicating that a record would have been updated * `:unrelate` - the related item is not destroyed, but the data is "unrelated", making this behave like `remove_from_relationship/3`. The action should be: * many_to_many - the join resource row is destroyed * has_many - the destination_field (on the related record) is set to `nil` * has_one - the destination_field (on the related record) is set to `nil` * belongs_to - the source_field (on this record) is set to `nil` * `{:unrelate, :action_name}` - the record is unrelated using the provided update action. The action should be: * many_to_many - a destroy action on the join resource * has_many - an update action on the destination resource * has_one - an update action on the destination resource * belongs_to - an update action on the source resource """ ], meta: [ type: :any, doc: "Freeform data that will be retained along with the options, which can be used to track/manage the changes that are added to the `relationships` key." ] ] @doc false def manage_relationship_schema, do: @manage_opts @doc """ Manages the related records by creating, updating, or destroying them as necessary. Keep in mind that the default values for all `on_*` are `:ignore`, meaning nothing will happen unless you provide instructions. ## Options #{Ash.OptionsHelpers.docs(@manage_opts)} Each call to this function adds new records that will be handled according to their options. For example, if you tracked "tags to add" and "tags to remove" in separate fields, you could input them like so: ```elixir changeset |> Changeset.manage_relationship( :tags, [%{name: "backend"}], on_lookup: :relate, #relate that tag if it exists in the database on_no_match: :error # error if a tag with that name doesn't exist ) |> Changeset.manage_relationship( :tags, [%{name: "frontend"}], on_no_match: :error, # error if a tag with that name doesn't exist in the relationship on_match: :unrelate # if a tag with that name is related, unrelate it ) ``` When calling this multiple times with the `on_missing` option set, the list of records that are considered missing are checked after each set of inputs is processed. For example, if you manage the relationship once with `on_missing: :unrelate`, the records missing from your input will be removed, and *then* your next call to `manage_relationship` will be resolved (with those records unrelated). For this reason, it is suggested that you don't call this function multiple times with an `on_missing` instruction, as you may be surprised by the result. If you want the input to update existing entities, you need to ensure that the primary key (or unique identity) is provided as part of the input. See the example below: Changeset.manage_relationship( changeset, :comments, [%{rating: 10, contents: "foo"}], on_no_match: {:create, :create_action}, on_missing: :ignore ) Changeset.manage_relationship( changeset, :comments, [%{id: 10, rating: 10, contents: "foo"}], on_match: {:update, :update_action}, on_no_match: {:create, :create_action}) This is a simple way to manage a relationship. If you need custom behavior, you can customize the action that is called, which allows you to add arguments/changes. However, at some point you may want to forego this function and make the changes yourself. For example: input = [%{id: 10, rating: 10, contents: "foo"}] changeset |> Changeset.after_action(fn _changeset, result -> # An example of updating comments based on a result of other changes for comment <- input do comment = MyApi.get(Comment, comment.id) comment |> Map.update(:rating, 0, &(&1 * result.rating_weight)) |> MyApi.update!() end {:ok, result} end) ## Using records as input Records can be supplied as the input values. If you do: * if it would be looked up due to `on_lookup`, the record is used as-is * if it would be created due to `on_no_match`, the record is used as-is * Instead of specifying `join_keys`, those keys must go in `__metadata__.join_keys`. If `join_keys` is specified in the options, it is ignored. For example: ```elixir post1 = changeset |> Api.create!() |> Ash.Resource.Info.put_metadata(:join_keys, %{type: "a"}) post1 = changeset2 |> Api.create!() |> Ash.Resource.Info.put_metadata(:join_keys, %{type: "b"}) author = Api.create!(author_changeset) Ash.Changeset.manage_relationship( author, :posts, [post1, post2], on_lookup: :relate_and_update ) ``` """ def manage_relationship(changeset, relationship, input, opts \\ []) def manage_relationship(changeset, relationship, "", opts) do manage_relationship(changeset, relationship, nil, opts) end def manage_relationship(changeset, relationship, input, opts) do opts = Ash.OptionsHelpers.validate!(opts, @manage_opts) case Ash.Resource.Info.relationship(changeset.resource, relationship) do nil -> error = NoSuchRelationship.exception( resource: changeset.resource, name: relationship ) add_error(changeset, error) %{cardinality: :one, type: type} = relationship when is_list(input) and length(input) > 1 -> error = InvalidRelationship.exception( relationship: relationship.name, message: "Cannot manage to a #{type} relationship with a list of records" ) add_error(changeset, error) %{writable?: false} = relationship -> error = InvalidRelationship.exception( relationship: relationship.name, message: "Relationship is not editable" ) add_error(changeset, error) relationship -> if relationship.cardinality == :many && is_map(input) && !is_struct(input) do case map_input_to_list(input) do {:ok, input} -> manage_relationship(changeset, relationship.name, input, opts) :error -> manage_relationship(changeset, relationship.name, List.wrap(input), opts) end else input = changeset.resource |> Ash.Resource.Info.related(relationship.name) |> Ash.Resource.Info.primary_key() |> case do [key] -> input |> List.wrap() |> Enum.map(fn input -> if is_map(input) || is_list(input) do input else %{key => input} end end) _ -> input end if Enum.any?( List.wrap(input), &(is_struct(&1) && &1.__struct__ != relationship.destination) ) do add_error( changeset, InvalidRelationship.exception( relationship: relationship.name, message: "Cannot provide structs that don't match the destination" ) ) else relationships = changeset.relationships |> Map.put_new(relationship.name, []) |> Map.update!(relationship.name, &(&1 ++ [{input, opts}])) %{changeset | relationships: relationships} end end end end defp map_input_to_list(input) do input |> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} when is_integer(key) -> {:cont, {:ok, [{key, value} | acc]}} {key, value}, {:ok, acc} when is_binary(key) -> case Integer.parse(key) do {int, ""} -> {:cont, {:ok, [{int, value} | acc]}} _ -> {:halt, :error} end _, _ -> {:halt, :error} end) |> case do {:ok, value} -> {:ok, value |> Enum.sort_by(&elem(&1, 0)) |> Enum.map(&elem(&1, 1))} :error -> :error end end @doc """ Appends a record or a list of records to a relationship. Alias for: ```elixir manage_to_relationship(changeset, relationship, input, on_lookup: :relate, # If a record is not in the relationship, and can be found, relate it on_no_match: :error, # If a record is not found in the relationship or the database, we error on_match: :ignore, # If a record is found in the relationship we don't change it on_missing: :ignore, # If a record is not found in the input, we ignore it ) ``` Provide `opts` to customize/override the behavior. """ @spec append_to_relationship( t, atom, Ash.Resource.record() | map | term | [Ash.Resource.record() | map | term], Keyword.t() ) :: t() def append_to_relationship(changeset, relationship, record_or_records, opts \\ []) do manage_relationship( changeset, relationship, record_or_records, Keyword.merge( [ on_lookup: :relate, on_no_match: :error, on_match: :ignore, on_missing: :ignore, authorize?: false ], opts ) ) end @doc """ Removes a record or a list of records to a relationship. Alias for: ```elixir manage_relationship(changeset, relationship, record_or_records, on_no_match: :error, # If a record is not found in the relationship, we error on_match: :unrelate, # If a record is found in the relationship we unrelate it on_missing: :ignore, # If a record is not found in the relationship authorize?: false ) ``` """ @spec remove_from_relationship( t, atom, Ash.Resource.record() | map | term | [Ash.Resource.record() | map | term], Keyword.t() ) :: t() def remove_from_relationship(changeset, relationship, record_or_records, opts \\ []) do manage_relationship( changeset, relationship, record_or_records, Keyword.merge( [ on_no_match: :error, on_match: :unrelate, on_missing: :ignore, authorize?: false ], opts ) ) end @doc """ Alias for: ```elixir manage_relationship( changeset, relationship, record_or_records, on_lookup: :relate, # If a record is not found in the relationship, but is found in the database, relate it and apply the input as an update on_no_match: :error, # If a record is not found in the relationship or the database, we error on_match: :ignore, # If a record is found in the relationship we make no changes to it on_missing: :unrelate, # If a record is not found in the relationship, we unrelate it authorize?: false ) ``` """ @spec replace_relationship( t(), atom(), Ash.Resource.record() | map | term | [Ash.Resource.record() | map | term] | nil, Keyword.t() ) :: t() def replace_relationship(changeset, relationship, record_or_records, opts \\ []) do manage_relationship( changeset, relationship, record_or_records, Keyword.merge( [ on_lookup: :relate, on_no_match: :error, on_match: :ignore, on_missing: :unrelate, authorize?: false ], opts ) ) end @doc "Returns true if an attribute exists in the changes" @spec changing_attribute?(t(), atom) :: boolean def changing_attribute?(changeset, attribute) do Map.has_key?(changeset.attributes, attribute) end @doc "Returns true if a relationship exists in the changes" @spec changing_relationship?(t(), atom) :: boolean def changing_relationship?(changeset, relationship) do Map.has_key?(changeset.relationships, relationship) end @doc "Change an attribute only if is not currently being changed" @spec change_new_attribute(t(), atom, term) :: t() def change_new_attribute(changeset, attribute, value) do if changing_attribute?(changeset, attribute) do changeset else change_attribute(changeset, attribute, value) end end @doc """ Change an attribute if is not currently being changed, by calling the provided function Use this if you want to only perform some expensive calculation for an attribute value only if there isn't already a change for that attribute """ @spec change_new_attribute_lazy(t(), atom, (() -> any)) :: t() def change_new_attribute_lazy(changeset, attribute, func) do if changing_attribute?(changeset, attribute) do changeset else change_attribute(changeset, attribute, func.()) end end @doc """ Add an argument to the changeset, which will be provided to the action """ def set_argument(changeset, argument, value) do if changeset.action do with {:arg, argument} when not is_nil(argument) <- {:arg, Enum.find( changeset.action.arguments, &(&1.name == argument || to_string(&1.name) == argument) )}, {:ok, casted} <- cast_input(argument.type, value, argument.constraints), {:constrained, {:ok, casted}, argument} when not is_nil(casted) <- {:constrained, Ash.Type.apply_constraints(argument.type, casted, argument.constraints), argument} do %{changeset | arguments: Map.put(changeset.arguments, argument.name, casted)} else {:arg, nil} -> changeset {:constrained, {:ok, nil}, _argument} -> changeset {:constrained, {:error, error}, argument} -> add_invalid_errors(:argument, changeset, argument, error) {:error, error} -> add_invalid_errors(:argument, changeset, argument, error) end else %{changeset | arguments: Map.put(changeset.arguments, argument, value)} end end @doc """ Remove an argument from the changeset """ def delete_argument(changeset, argument_or_arguments) do argument_or_arguments |> List.wrap() |> Enum.reduce(changeset, fn argument, changeset -> %{changeset | arguments: Map.delete(changeset.arguments, argument)} end) end @doc """ Merge a map of arguments to the arguments list """ def set_arguments(changeset, map) do Enum.reduce(map, changeset, fn {key, value}, changeset -> set_argument(changeset, key, value) end) end @doc """ Force change an attribute if is not currently being changed, by calling the provided function See `change_new_attribute_lazy/3` for more. """ @spec force_change_new_attribute_lazy(t(), atom, (() -> any)) :: t() def force_change_new_attribute_lazy(changeset, attribute, func) do if changing_attribute?(changeset, attribute) do changeset else force_change_attribute(changeset, attribute, func.()) end end @doc "Calls `change_attribute/3` for each key/value pair provided" @spec change_attributes(t(), map | Keyword.t()) :: t() def change_attributes(changeset, changes) do Enum.reduce(changes, changeset, fn {key, value}, changeset -> change_attribute(changeset, key, value) end) end @doc "Adds a change to the changeset, unless the value matches the existing value" def change_attribute(changeset, attribute, value) do case Ash.Resource.Info.attribute(changeset.resource, attribute) do nil -> error = NoSuchAttribute.exception( resource: changeset.resource, name: attribute ) changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_error(changeset, error) %{writable?: false} = attribute -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_invalid_errors(:attribute, changeset, attribute, "Attribute is not writable") attribute -> with {:ok, prepared} <- prepare_change(changeset, attribute, value, attribute.constraints), {:ok, casted} <- cast_input(attribute.type, prepared, attribute.constraints, true), {:ok, casted} <- handle_change(changeset, attribute, casted, attribute.constraints), :ok <- validate_allow_nil(attribute, casted), {:ok, casted} <- Ash.Type.apply_constraints(attribute.type, casted, attribute.constraints) do data_value = Map.get(changeset.data, attribute.name) cond do is_nil(data_value) and is_nil(casted) -> %{changeset | attributes: Map.delete(changeset.attributes, attribute.name)} Ash.Type.equal?(attribute.type, casted, data_value) -> %{changeset | attributes: Map.delete(changeset.attributes, attribute.name)} true -> %{changeset | attributes: Map.put(changeset.attributes, attribute.name, casted)} end else {{:error, error_or_errors}, last_val} -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, last_val) } add_invalid_errors(:attribute, changeset, attribute, error_or_errors) :error -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_invalid_errors(:attribute, changeset, attribute) {:error, error_or_errors} -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_invalid_errors(:attribute, changeset, attribute, error_or_errors) end end end @doc false def cast_input(type, term, constraints, return_value? \\ false) def cast_input(type, value, constraints, return_value?) do value = handle_indexed_maps(type, value) case Ash.Type.cast_input(type, value, constraints) do {:ok, value} -> {:ok, value} {:error, error} -> if return_value? do {{:error, error}, value} else {:error, error} end end end defp handle_indexed_maps({:array, type}, term) when is_map(term) do term |> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} when is_integer(key) -> {:cont, {:ok, [{key, value} | acc]}} {key, value}, {:ok, acc} when is_binary(key) -> case Integer.parse(key) do {int, ""} -> {:cont, {:ok, [{int, value} | acc]}} _ -> {:halt, :error} end _, _ -> {:halt, :error} end) |> case do {:ok, value} -> value |> Enum.sort_by(&elem(&1, 0)) |> Enum.map(&elem(&1, 1)) |> Enum.map(&handle_indexed_maps(type, &1)) :error -> term end end defp handle_indexed_maps(_, value), do: value @doc "Calls `force_change_attribute/3` for each key/value pair provided" @spec force_change_attributes(t(), map) :: t() def force_change_attributes(changeset, changes) do Enum.reduce(changes, changeset, fn {key, value}, changeset -> force_change_attribute(changeset, key, value) end) end @doc "Changes an attribute even if it isn't writable" @spec force_change_attribute(t(), atom, any) :: t() def force_change_attribute(changeset, attribute, value) do case Ash.Resource.Info.attribute(changeset.resource, attribute) do nil -> error = NoSuchAttribute.exception( resource: changeset.resource, name: attribute ) add_error(changeset, error) attribute when is_nil(value) -> %{changeset | attributes: Map.put(changeset.attributes, attribute.name, nil)} attribute -> with {:ok, prepared} <- prepare_change(changeset, attribute, value, attribute.constraints), {:ok, casted} <- cast_input(attribute.type, prepared, attribute.constraints), {:ok, casted} <- handle_change(changeset, attribute, casted, attribute.constraints), {:ok, casted} <- Ash.Type.apply_constraints(attribute.type, casted, attribute.constraints) do data_value = Map.get(changeset.data, attribute.name) cond do is_nil(data_value) and is_nil(casted) -> changeset Ash.Type.equal?(attribute.type, casted, data_value) -> changeset true -> %{changeset | attributes: Map.put(changeset.attributes, attribute.name, casted)} end else :error -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_invalid_errors(:attribute, changeset, attribute) {:error, error_or_errors} -> changeset = %{ changeset | attributes: Map.put(changeset.attributes, attribute.name, value) } add_invalid_errors(:attribute, changeset, attribute, error_or_errors) end end end @doc "Adds a before_action hook to the changeset." @spec before_action( t(), (t() -> t() | {t(), %{notificactions: list(Ash.Notifier.Notification.t())}}) ) :: t() def before_action(changeset, func) do %{changeset | before_action: [func | changeset.before_action]} end @doc "Adds an after_action hook to the changeset." @spec after_action( t(), (t(), Ash.Resource.record() -> {:ok, Ash.Resource.record()} | {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())} | {:error, term}) ) :: t() def after_action(changeset, func) do %{changeset | after_action: [func | changeset.after_action]} end @doc "Returns the original data with attribute changes merged, if the changeset is valid." @spec apply_attributes(t()) :: {:ok, Ash.Resource.record()} | {:error, t()} def apply_attributes(%{valid?: true} = changeset) do {:ok, Enum.reduce(changeset.attributes, changeset.data, fn {attribute, value}, data -> Map.put(data, attribute, value) end)} end def apply_attribute(changeset), do: {:error, changeset} @doc "Clears an attribute or relationship change off of the changeset" def clear_change(changeset, field) do cond do attr = Ash.Resource.Info.attribute(changeset.resource, field) -> %{changeset | attributes: Map.delete(changeset.attributes, attr.name)} rel = Ash.Resource.Info.relationship(changeset.resource, field) -> %{changeset | relationships: Map.delete(changeset.relationships, rel.name)} true -> changeset end end @doc "Adds an error to the changesets errors list, and marks the change as `valid?: false`" @spec add_error(t(), term | String.t() | list(term | String.t())) :: t() def add_error(changeset, errors) when is_list(errors) do if Keyword.keyword?(errors) do %{ changeset | errors: [to_change_error(errors) | changeset.errors], valid?: false } else Enum.reduce(errors, changeset, &add_error(&2, &1)) end end def add_error(changeset, error) when is_binary(error) do add_error( changeset, InvalidChanges.exception(message: error) ) end def add_error(changeset, error) do %{changeset | errors: [error | changeset.errors], valid?: false} end defp to_change_error(keyword) do if keyword[:field] do InvalidAttribute.exception( field: keyword[:field], message: keyword[:message], vars: keyword ) else InvalidChanges.exception( fields: keyword[:fields] || [], message: keyword[:message], vars: keyword ) end end defp prepare_change(%{action_type: :create}, _attribute, value, _constraints), do: {:ok, value} defp prepare_change(changeset, attribute, value, constraints) do old_value = Map.get(changeset.data, attribute.name) Ash.Type.prepare_change(attribute.type, old_value, value, constraints) end defp handle_change(%{action_type: :create}, _attribute, value, _constraints), do: {:ok, value} defp handle_change(changeset, attribute, value, constraints) do old_value = Map.get(changeset.data, attribute.name) Ash.Type.handle_change(attribute.type, old_value, value, constraints) end defp validate_allow_nil(%{allow_nil?: false} = attribute, nil) do {:error, InvalidAttribute.exception( field: attribute.name, message: "must be present" )} end defp validate_allow_nil(_, _), do: :ok defp add_invalid_errors(type, changeset, attribute, message \\ nil) do messages = if Keyword.keyword?(message) do [message] else List.wrap(message) end Enum.reduce(messages, changeset, fn message, changeset -> opts = error_to_exception_opts(message, attribute) exception = case type do :attribute -> InvalidAttribute :argument -> InvalidArgument end Enum.reduce(opts, changeset, fn opts, changeset -> error = exception.exception( field: Keyword.get(opts, :field), message: Keyword.get(opts, :message), vars: opts ) add_error(changeset, error) end) end) end @doc false def error_to_exception_opts(message, attribute) do case message do keyword when is_list(keyword) -> fields = case List.wrap(keyword[:fields]) do [] -> List.wrap(keyword[:field]) fields -> fields end fields |> case do [] -> [ keyword |> Keyword.put( :message, add_index(keyword[:message], keyword) ) |> Keyword.put(:field, attribute.name) ] fields -> Enum.map( fields, &Keyword.merge(message, field: attribute.name, message: add_index(add_field(keyword[:message], "#{&1}"), keyword) ) ) end message when is_binary(message) -> [[field: attribute.name, message: message]] _ -> [[field: attribute.name]] end end defp add_field(message, field) do "at field #{field} " <> message end defp add_index(message, opts) do if opts[:index] do "at index #{opts[:index]} " <> message else message end end end