I am wondering why when I ran Ecto.Changeset.apply_changes(changeset) the prepared changes function on that changeset did not run. I have a changeset that I am building from a Phoenix form; after validation occurs and user submits the form, I want to apply those params to my struct with a prepared change to increment a value of one of the inputs.
Simple example demonstrating the unexpected behavior of apply_changes/1
We have a sample struct. It casts some params then adds a prepare_changes function to that changeset. When calling apply_changes that prepare_changes function is not run so our resulting struct is incorrect.
Example:
defmodule InputData do
use Ecto.Schema
@primary_key false
embedded_schema do
field :value, :integer
end
end
%InputData{value: nil}
|> Ecto.Changeset.cast(%{"value" => "1"}, [:value])
|> Ecto.Changeset.validate_number(:value, [greater_than: 0])
|> Ecto.Changeset.prepare_changes(fn changeset ->
incremented_value = Ecto.Changeset.get_field(:value, 0) + 1
Ecto.Changeset.put_change(changeset, :value, incremented_value)
end)
|> Ecto.Changeset.apply_changes()
The output of this is not the expected %InputData{value: 2} where the params casts the value to 1 then is incremented again by 1 in the prepare_changes/2. Why is this? Since apply_changes/1 results in a struct and not a changeset there is no risk of firing the prepared_changes functions again if the developer needed to to a Repo insert. In my case I am never inserting this struct into a database and thus need these prepared_changes to run when calling apply_changes.
In summary this feels like a bug or missing implementation in Ecto Changesets that I seem unable to come up with a convincing argument as to why it shouldn’t behave like this.
Provides a function executed by the repository on insert/update/delete.
For Ecto.Changeset.apply_changes there’s no repository involved and it’s neither of those mentioned actions either.
Usually prepare_changes is used to execute code within the same transaction as what the changeset applies to the database, which needs to be provided as a callback as that transaction is only started later (by the repo). If you don’t have a transaction you can directly apply the changes to the changeset in place. No need to delay that.
The docs show an interesting example, where the changeset isn’t modified at all and there are just side effects (updating the database). Most of my uses of prepare_changes do last minute modifications the changeset, and in that regard, I also found it a bit surprising that apply_changes doesn’t apply prepare_changes.
I also use Ecto.Changeset a lot to validate and prepare data for other databases (like Cassandra), so an apply_changes_with_prepare would be useful for me.
In addition to @cjbottaro’s comment, using changesets to validate Phoenix forms makes the use case for apply_changes_with_prepare even more obvious.
A form would have two event triggers validate and submit. You would only want to directly apply the modification in prepare_changes during the submit event and not the validate event. This has first class support if you insert your changeset into a DB but not if you simple need to run apply_changes/1 and store else where such as Cassandra.
I do not want to do my increment to the value after each validation step, only at the end step during apply_changes
You can always call the callback by yourself (acting as your own repository). But this is not a bug. Ecto.Changeset.apply_changes can easily be called in a context, which is not meant to execute those callbacks.
Per the docs prepare is not intended for public consumption. This also complicates matters when having nested changesets with embeds_many. Our use case has 5 levels of nesting which means I needed to create a recursive solution.
When doing this though I couldn’t help but feel that this should just be first class supported as this is already implemented when saving the changeset through Ecto.
Also just because the current functionality doesn’t exist doesn’t mean it shouldn’t and there isn’t a valid use case. There should be a separate function or an option on apply_changes to run the prepared changes.
For anyone that needs it, this is the solution I made
@doc"""
Runs all prepared functions and apply changes to a changeset. See implimentation of run/1 for more details
"""
def apply_with_prepared_changes(%Ecto.Changeset{} = changeset) do
run(changeset)
|> Ecto.Changeset.apply_changes()
end
@doc """
Runs all prepared functions on a changeset and its associated changesets.
**WARNING** This should not called before saving a record through Ecto. This will result in
duplicate prepared function calls and have bad consequences.
"""
defp run(%Ecto.Changeset{} = changeset) do
if changeset.changes != %{} do
Enum.reduce(changeset.changes, changeset, fn {field, value}, acc ->
new_changeset = run_prepared_fun(value)
put_embed_or_change(acc, field, new_changeset)
end)
else
changeset
end
end
defp run(changeset), do: changeset
defp run_prepared_fun(changesets) when is_list(changesets) do
Enum.map(changesets, fn embeded_changeset ->
run_prepared_fun(embeded_changeset)
end)
end
defp run_prepared_fun(%Ecto.Changeset{} = changeset) do
Enum.reduce(changeset.prepare, changeset, fn fun, acc ->
fun.(acc)
end)
|> run()
end
defp run_prepared_fun(changeset), do: changeset
defp put_embed_or_change(changeset, field, value) do
schema = changeset.data.__struct__
field_type = schema.__schema__(:type, field)
case field_type do
{_, {Ecto.Embedded, _}} -> Ecto.Changeset.put_embed(changeset, field, value)
{_, {Ecto.Association, _}} -> Ecto.Changeset.put_assoc(changeset, field, value)
_ -> Ecto.Changeset.put_change(changeset, field, value)
end
end
Ecto has quite a strict division between pure Ecto.Changeset apis and sideeffects, which run in Repo apis. prepare_changes is meant to allow for sideeffects to happen - hence the coupling to a repository, which provides the context for the sideeffects to succeed and happen in the same transaction. Take the example placed in the docs. You don’t want to run this when doing Ecto.Changeset.apply_changes. It would change your db in the process on an api, which is not meant to access the database.
I can see how it would be useful, but I’d argue that you’re using prepare_changes for something it’s not meant for.
Do you know if there is an easy way to traverse nested changesets? I.e. a way to use the technique you describe above, but traverse changesets that have several levels of cast_embed and cast_assoc?
There is not. Ecto internally uses Ecto.Embedded.prepare with embeds from schema.__schema__(:embeds) but that is private and requires a repo adapter. I’ll note that Ecto actually reduces over the reversed list of prepares, but with one function the order doesn’t matter.
The internals where Ecto calls these functions are private and very much in the context of a repo. I’ll concur with @LostKobrakai that this is using prepare_changes for something it isn’t meant for. You should just be making changes to your changeset directly before calling apply_changes.