How to call function based on optional keyword list key?

I hope I describe the problem correctly

In a phoenix changeset I want to use a function “set_area_of_residence_type/1”

   changeset
    |> cast(params, [(... some stuff), :country, :area_of_residence_type, :area_of_residence])
    |> validate_required([ :country, :area_of_residence])
    |> validate_inclusion(:country, country_codes)
    |> set_area_of_residence_type()

Where country codes is just a list of country codes

[“ZW”, “ZM”, “ZA”, “YT”, “YE”, “WS”, “WF”, “VU”, “VN”, “VI”, “VG”, “VE”, “VC”,
“VA”, “UZ”, “UY”, “US”, “UM”, “UG”, “UA”, “TZ”, “TW”, “TV”, “TT”, “TR”, “TO”,
“TN”, “TM”, “TL”, “TK”, “TJ”, “TH”, “TG”, “TF”, “TD”, “TC”, “SZ”, “SY”, “SX”,
“SV”, “ST”, “SS”, “SR”, “SO”, “SN”, “SM”, “SL”, “SK”, “SJ”, “SI”, …]

I call various functions with pattern matching

def set_area_of_residence_type(changeset = %Ecto.Changeset{ changes: %{country: "ZW"}})  do
    #do stuff
end

But I want to call a function when it matches

def set_area_of_residence_type(changeset = %Ecto.Changeset{ errors: [countries: _ ]})  do
    #do stuff
end

This pattern match doesn’t work. As soon as there are other errors in the errors list the match fails. The docs mention this.

Then I tried to do it with guards.

def set_area_of_residence_type(changeset) when :country in changeset.errors do

but this gives compilation error

** (ArgumentError) invalid args for operator “in”, it expects a compile-time list or compile-time range on the right side when used in guard expressions, got: changeset.errors()

Then I tried with macros. Macros are impossible to understand for me so far so I am not sure if this is correct

defmodule MySite.Models.Guards do
  defmacro has_country_error(changeset) do
    quote do
      Keyword.has_key?(unquote(changeset)[:errors], :country)
    end
  end
end

and in my model file

def set_area_of_residence_type(changeset) when has_country_error(changeset) do

Now I get a new compilation error

** (CompileError) web/models/user_profile.ex:69: cannot invoke remote function Access.get/2 inside guard

Is there any way to achieve what I want?

I guess I could check with an if inside a function for the existence of :countries inside the errors list and then call the appropriate function with the appropriate name but where is the fun in that?

1 Like

Sooo. The sad response is: there is no way to match a single value of a keyword list, so pattern matching on the params is not possible.

Now, let’s go to your other attempts. The first guard clause you checked if the value :country was inside the changeset.errors, and changeset.errors is a keyword lists, which means it follows this pattern [{:country, "value1"}, {:area_of_residence, "value2"}], which is syntax sugared to [country: "value1", area_of_residence: "value2"]. If you want to match one of the keys, you should try :country in Keyword.keys(changeset.errors).

Well, unfortunately, that will not work on the guard clause when too. For the same reason your second attempt didn’t: there is a limited number of expressions that are allowed in guard clauses, check the list here.

So, the easiest solution you have for now is the if inside the function. I was really struggling to discover another way to do it, but couldn’t. Sorry :confused:

1 Like

@josevalim about this situation: is there any reason to Ecto.Changeset.errors be a keyword list and not a map? Just wondering. :slight_smile:

It is older than maps at least. ^.^

1 Like

LoL. Didn’t know that maps are new to Elixir core, or that Ecto was so old. LoL…

So, do you think is there a reason to stay as it is, and not turn it into a map?

Thank you for the effort. I was hoping for a solution with macros or at least a way to invoke functions against guard. It seems it can’t be done.

I found a blog post today (unfortunately I didn’t save the link) that explained why we can’t run arbitrary functions in guard clauses. The reason is that it can’t be guaranteed the arbitrary function will have no side effects.

1 Like

Yes, there is no way to do it. The amount of expressions allowed in guards are limited.

Backwards compatibility is the main reason at this point. Keep in mind the current :errors may have duplicate keys to signal different errors on the same field. So if we wanted to use maps, we would need to change the structure from key => value to key => [value] to support multiple errors on the same key which would be a breaking change.

3 Likes

Yep, I was thinking about it, and I guess you’re right. Thank you :slight_smile: