elixir map with mixed keys
Asked Answered
H

4

7

in the Phoenix application, I have a function that takes two maps, and creates two entries in the database via Ecto.Changeset.

def create_user_with_data(user_attrs, data_attrs) do
    name = cond do
        data_attrs["name"] ->
            data_attrs["name"]
        data_attrs[:name] ->
            data_attrs[:name]
        true -> nil
    end
    Ecto.Multi.new()
    |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, Map.put(user_attrs, :name, name)))
    |> Ecto.Multi.run(:user_data, fn(%{user: user}) ->
        %MyApp.Account.UserData{}
        |> MyApp.Account.UserData.changeset(Map.put(data_attrs, :user_id, user.id))
        |> Repo.insert()
    end)
    |> Repo.transaction()
end

because the keys in these map can be both atoms and lines, I have to check these keys.

but the expression

Map.put(user_attrs, :name, name)

will cause an error

** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:name => "John", "email" => "[email protected]"}

if the keys are strings.

Is there any best practice in dealing with this issue?

Haroldson answered 17/11, 2017 at 16:6 Comment(0)
L
6

I'd convert all the keys to atoms first and then use atoms everywhere.

def key_to_atom(map) do
  Enum.reduce(map, %{}, fn
    {key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
    # 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_binary(key) -> Map.put(acc, String.to_existing_atom(key), value)
  end)
end

Then, convert pass all such maps through this function:

user_attrs = user_attrs |> key_to_atom
data_attrs = data_attrs |> key_to_atom

Now you can Map.put atom keys whenever you want to.

Leisha answered 17/11, 2017 at 16:14 Comment(1)
The potential problem with this is that's an attack vector, as in, if someone where to know, they could overflow the system quite easily by sending random strings through the API, and you would be transforming all of them to atoms. I think it makes more sense to do it the other way around and transform them all to strings, then Ecto ONLY transforms the explicitly required ones to atoms.Roque
C
10

Explicitly cast all the keys to strings with Kernel.to_string/1:

data_attrs = for {k, v} <- data_attrs,
               do: {to_string(k), v}, into: %{}
Contrive answered 17/11, 2017 at 16:13 Comment(0)
L
6

I'd convert all the keys to atoms first and then use atoms everywhere.

def key_to_atom(map) do
  Enum.reduce(map, %{}, fn
    {key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
    # 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_binary(key) -> Map.put(acc, String.to_existing_atom(key), value)
  end)
end

Then, convert pass all such maps through this function:

user_attrs = user_attrs |> key_to_atom
data_attrs = data_attrs |> key_to_atom

Now you can Map.put atom keys whenever you want to.

Leisha answered 17/11, 2017 at 16:14 Comment(1)
The potential problem with this is that's an attack vector, as in, if someone where to know, they could overflow the system quite easily by sending random strings through the API, and you would be transforming all of them to atoms. I think it makes more sense to do it the other way around and transform them all to strings, then Ecto ONLY transforms the explicitly required ones to atoms.Roque
G
2

I've used this recursive solution. Works great for nested map, and for mixed content (atom and string keys mixed) as well

defmodule SomeProject.MapHelper do
  def to_params(map) do
    Enum.map(map, &process_pair/1) |> Enum.into(%{})
  end

  defp process_pair({k, v}) do
    {"#{k}", process_value(v)}
  end

  defp process_value(v) when is_map(v) do
    to_params(v)
  end

  defp process_value(v), do: v
end

Here are the tests

defmodule CrecerInversiones.MapHelperTest do
  use CrecerInversiones.DataCase
  alias CrecerInversiones.MapHelper

  describe "to_params" do
    test "convert atom keys to strings" do
      params = MapHelper.to_params(%{a: "hi params"})

      assert params == %{"a" => "hi params"}
    end

    test "convert nested maps" do
      params = MapHelper.to_params(%{a: "hi params", b: %{z: "nested map"}})

      assert params == %{"a" => "hi params", "b" => %{"z" => "nested map"}}
    end

    test "accept mixed content" do
      params = MapHelper.to_params(%{"a" => "hi params", "b" => %{z: "nested map"}})

      assert params == %{"a" => "hi params", "b" => %{"z" => "nested map"}}
    end
  end
end
Gabo answered 19/11, 2020 at 20:5 Comment(1)
Any particular reason for using string interpolation rather than Kernal.to_string/1 directly?Garganey
G
0

@Dogberts solution is great. But if you want to extend it and transform a multidimensional map, you could use some recursion:

def map_keys_to_atom(map) when is_map(map) do
  Enum.reduce(map, %{}, fn
    {key, value}, acc when is_atom(key) ->
      Map.put(acc, key, map_keys_to_atom(value))

    {key, value}, acc when is_binary(key) ->
      Map.put(acc, String.to_existing_atom(key), map_keys_to_atom(value))
  end)
end

def map_keys_to_atom(map), do: map
Gayn answered 5/9, 2019 at 8:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.