Map with atom, string, keyword list - it's blocking me so much as a beginner Elixr enthusiast

Hi,

I’m constantly being blocked by maps’ different construct %{:atom => “value”} vs. %{“string” => “value”} vs %{name: “value”}

It does not appears to be consistent enough and in my case are often the cause of all “blockage” I have.

I understand them, I’m a long time Elm dev/fan and used to FP, but I don’t know why Elixir’s maps are so weird to me.

For instance, I have this simple test that I’m trying to understand the error:

test "renders created category when valid", %{conn: conn} do
      conn = post(conn, Routes.category_path(conn, :create), %{name: "cat name here"})
      assert %{"name" => "cat name here"}
    end

In my controller:

def create(conn, %{} = payload) do
      {account_id, _user_id} = conn.assigns.current_user

      payload = Map.put(payload, :account_id, account_id)

      cat = KB.create_category(payload)
      render(conn, "short.html", %{category: cat})
    end

The error I’m getting:

1) test creates category renders created category when valid (Parle.CategoryControllerTest)
     test/parle_web/controllers/category_controller_test.exs:19
     ** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:account_id => "14ab1382-fe6a-4fd4-af6c-58c6a86c995e", "name" => "cat name here"}
     code: conn = post(conn, Routes.category_path(conn, :create), %{name: "cat name here"})

If I use something like this instead in the test `%{:name => “cat name here”} I’m getting:

1) test creates category renders created category when valid (Parle.CategoryControllerTest)
     test/parle_web/controllers/category_controller_test.exs:19
     ** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:account_id => "91caf133-d2ae-4db0-b325-bef317f50eb3", "name" => "cat name here"}
     code: conn = post(conn, Routes.category_path(conn, :create), %{:name => "cat name here"})

This one just does not make sense to me.

Changing the Map.put(payload, "account_id", account_id) does not really help either.

I’m not coming from RoR, so to me sugar syntax is not attracting at all, and I prefer clarity, but still wants to jump on Elixir.

My main question would be, how am I suppose to pass data in the test that I can add an entry in the map so the schema validation works and still have the ~same atom/strong/keyword whatever identical everywhere.

Also if there’s an obvious way to grasp this part that I might have miss, I’d appreciate any pointer, they are just not very clear to me it appears.

Thanks :wink:

2 Likes

Note that %{name: "cat name here"} and %{:name => "cat name here"} are the same thing, they are just alternative syntaxes for it. The reason for the first syntax is that it’s very common to build atom-keyed maps in your own code and that is a more readable syntax for it (that also reminds of keyword lists).

Phoenix controllers convert all input params to string-keyed maps. This is because converting untrusted strings to atoms is dangerous as it can lead to overflowing of the atom table (atoms are not garbage collected), which kills the VM. This is why your map is string-keyed inside the controller action even though you sent it in the test as atom-keyed.

As to your issue, you could indeed do Map.put(payload, "account_id", account_id) and it should fix the error. Or you could modify your KB.create_category function to take in the user account ID as argument. So it would be KB.create_category(payload, account_id), and you’d add the account ID as a change in the changeset with put_change(:account_id, account_id) or something similar.

5 Likes

Your controller will always receive their “payload” as %{optional(String.t) => String.t} due to limitations of how HTTP works.

If you want to pass the payload as is to the changeset you need to use a string key in the Map.put/3 call.

Also there is no semantic difference between %{:foo => "bar"} and %{foo: "bar"} both maps are exactly identical, it is just some syntactic sugar.

2 Likes

Thanks @Nicd and @NobbZ re: controller having the %{“string” => “value”} converted, it was the missing pieces.

What’s the “normal” way to send those to the

@dstpierre The normal way is either pass %{optional(String.t) => any} or %{optional(atom) => any} to the Ecto.Changeset.cast, but maps with hybrid types of keys.

In tests, it’s handy to just use atom keys, but you can also use string keys (actually, I recommend using string keys). In production, you should not convert string keys to atom keys because atoms are not garbage collected, and the internet is scary.

3 Likes

What does it do? That sounds like exactly the solution to the mixed-keys CastError, since payload starts with string keys.