If you work with Phoenix, it's only a matter of time before you encounter an error like the following:
(Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:priority => 0.2, :vote_total => 2, "cost" => "10"}
The problem here is that while the struct above passed into Ecto is a valid map, but it's not valid input for the Ecto changeset function.
The keys of a map are usually strings or atoms, but the can be almost anything. But when passing a map of attributes to Ecto, they need to be either strings or atoms and they all need to be the same type.
Why the problem occurs
When a web request comes in (e.g. a GET to /users/5 or a POST to /users), the attributes parsed out in the controller will be strings (e.g. "5"). Where we often get into trouble is when writing code in the Context modules that calculate or fetch data from the DB or a cache and then mingle it with attributes coming in from the controller.
Here's an example from the voting system for feature requests on this site:
def update_request(%Request{} = request, attrs) do
votes = count_votes(request.id)
cost = attrs["cost"] |> to_string |> String.to_integer()
priority = Request.calculate_priority(%{request | vote_total: votes, cost: cost})
new_attrs = Map.merge(atomic_attrs, %{vote_total: votes, priority: priority})
request
|> Request.changeset(new_attrs)
|> Repo.update()
end
Requests on Alchemist Camp are categorized by priority
, which is computed from the total_votes
all users have spent on the feature or tutorial and the difficulty, aka cost
I assign to them. When a request is updated, I might update the difficulty, so it's necessary to calculate the priority before calling Repo.update()
. Any new value for cost submitted in the attrs
needs to override the prior value when the new priority is being calculated.
The above code ensures that the cost used for the calculation will be an integer even if a string is passed in, but string-keyed attributes coming from the controller will cause the mixed keys Ecto.CastError
shown at the top of this page.
A way to fix it
One solution is to create a utility function that converts keys from strings to atoms. We should be careful about creating new atoms in general, since they don't get garbage collected. Fortunately, in this case, all the atoms already exist because they're part of our DB schemas.
def key_to_atom(map) do
Enum.reduce(map, %{}, fn
# String.to_existing_atom saves us from overloading the VM by
# creating too many atoms. It'll always succeed because all the fields
# in the database already exist as atoms at runtime.
{key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
{key, value}, acc when is_binary(key) -> Map.put(acc, String.to_existing_atom(key), value)
end)
end
With this utility function, it's straight-forward to eliminate the error in update_request
:
def update_request(%Request{} = request, attrs) do
votes = count_votes(request.id)
atomic_attrs = Campsite.key_to_atom(attrs)
cost = atomic_attrs.cost |> to_string |> String.to_integer()
priority = Request.calculate_priority(%{request | vote_total: votes, cost: cost})
new_attrs = Map.merge(atomic_attrs, %{vote_total: votes, priority: priority})
request
|> Request.changeset(new_attrs)
|> Repo.update()
end