Different behavior in changeset `changes` and `apply_changes` when input data has `atom` vs `string` keys

Hi, I am confused about different behavior in changesets when input data has atom vs string keys.

The Scenario

For example, let’s say I have a Company schema and changeset:

schema "company" do
  email: :string
  many_to_many: :categories, join_through: "companies_categories"
  has_many :addresses, Addresses.Address
end
def changeset(attrs) do
  %Company{}
  |> cast([:email])
  |> cast_assoc(:address)
end

I am omitting the definition of Address schema for the sake of brevity.

Then, considering I have the following data arriving at the controller:

%{
  "company" => %{
    "email" => "some@email.com",
    "categories_ids" => [1, 2],
    "address" => %{
      "address_line1" => "some address_line1"
      "city_id" => 2
    }
  }
}

The controller will receive it and call a function defined in my context module that will do the hard work:

# in CompanyController:
def create_company(conn, %{"company" => attrs}) do
  Accounts.create_company(attrs)
  # ...
end

# in Accounts module
def create_company(attrs) do
  company_changeset = Company.changeset(attrs)
  categories_ids_changeset = categories_ids_changeset(attrs)
  # ... create the company,
  #      obtain the categories_ids from its changeset,
  #        retrieve the categories from db using the ids,
  #        and put_assoc(:categories, company) 
end

def categories_ids_changesest(attrs) do
  types = %{categories_ids: {:array, :integer}}

  {attrs, types}
  |> cast(attrs, Map.keys(types))
end

In this case I am using a schemaless changeset to validate input data that is slightly different from data in my schema.

The struggle:

I can not find a standard way to retrieve the changes from categories_ids_changeset.

So, ok, I can do categories_ids_changeset.changes. That will work when the input data has string keys. But it will not if it has atom keys. See:

iex(80)> attrs = %{"categories_ids" => [1, 2]}
iex(81)> types = %{categories_ids: {:array, :integer}}
iex(82)> changeset = {attrs, types} |> Changeset.cast(attrs, Map.keys(types))
#Ecto.Changeset<action: nil, changes: %{categories_ids: [1, 2]}, errors: [],
 data: %{"categories_ids" => [1, 2]}, valid?: true>
iex(84)> attrs = %{categories_ids: [1, 2]}
iex(85)> types = %{categories_ids: {:array, :integer}}
iex(86)> changeset = {attrs, types} |> Changeset.cast(attrs, Map.keys(types))
#Ecto.Changeset<action: nil, changes: %{}, errors: [],
 data: %{categories_ids: [1, 2]}, valid?: true>

Note that in the second changeset, whose initial attrs had atom keys, changes is empty!

I tried to appy_changes/1 in the changeset, but when there are string keys, it will generate a map with mixed keys…

%{:categories_ids => [1, 2], "categories_ids" => [1, 2]}

This behavior is messing up with my tests, as for testing CompanyController.create_company/2, I pass data with string keys, but when testing my context modules, i.e. Accounts.create_company, I tend to use atom keys.

I thought about using apply_changes/1 and they filter out string keys… but I don’t know, it seems hacky, so maybe I am missing something simpler that can solve it in a better way.

Thanks in advance.

2 Likes