Generate module functions from a list of "values" (to solve DRY)

Hi,
I’m trying to make a parsing pipeline for one of my params (Phoenix’s payload of endpoint) into a struct.
That involves parsing many dates into DateTime.t().

First, I had this:

def parse_params(%{"date1" => date1} = params) when is_binary(date1) do
  date1
  |> DateTime.from_iso8601()
  |> case do
    {:ok, parsed, _} ->
      params
      |> Map.put("date1", parsed)
      |> parse_params()

    error ->
      error
  end
end

# ... same repeating functions for other date fields

def parse_params(params) do
  {
    :ok,
    %MyStruct{
      title: params["title"]
      number_field: String.to_integer(params["number_field"]),
      date1: params["date1"]
    }
  }
end

The first “overload” will get repeated for each field and that is what I would like to prevent…
So I tried:

~w(date1 date2 date3)
|> Enum.map(fn key ->
  quote do
    def parse_params(%{unquote(key) => binary_val} = params) when is_binary(binary_val) do
      binary_val
      |> DateTime.from_iso8601()
      |> case do
        {:ok, parsed, _} ->
          params
          |> Map.put(unquote(key), parsed)
          |> parse_params()

        error ->
          error
      end
    end
  end
end)
|> Enum.reduce(
  quote do
  end,
  fn f, acc ->
    quote do
      unquote(f)

      unquote(acc)
    end
  end
)
|> Code.eval_quoted()

This would produce AST with expected functions but throws an error that says the def can be called only inside a module. Which in fact is called inside a module…
I can understand that there is an isolation of the AST but how would I “merge”/pass the outer context to the AST?

Ofcourse I could make some workaround… And I probably will… But I just wondered if the solution described above makes any sense for even considering…

Thanks

1 Like

First question, why don’t you produce much smaller functions which all call a generalized function that does most of the job?

To reply to the implicit question, why it doesn’t work. I think, your Module is not defined yet, when you try to call def.

Example

defmodule Test do
  # a common definition
  def(a(b), do: b)
  |> IO.inspect(label: "#{__ENV__.line}")

  # your approach (simplified)
  quote do
    def c(d), do: d
  end
  |> IO.inspect(label: "#{__ENV__.line}")
end

When you compile this, you will see the different outputs:

Compiling 1 file (.ex)
3: {:a, 1}
8: {:def, [context: Test, import: Kernel],
 [{:c, [context: Test], [{:d, [], Test}]}, [do: {:d, [], Test}]]}
:ok
1 Like

Yeah, that’s what I have done after rethinking it… But the reason I tried this approach was that I wanted to leverage the function param pattern matching which made it impossible with the map’s key…

And also I posted this just out of curiosity if something like this is somewhat possible…

If you are curious, see how the Unicode module does it in Elixir Core.

2 Likes

You can do a lot of interesting stuff with macros and metaprogramming in general. Pretty sure what you wanted is at least mostly achievable.

Assuming this code is inside a defmodule, you don’t need eval_quoted - the goal of a macro is to return AST fragments that become part of the module’s code.

You likely don’t need the reduce at the end either, for similar reasons.

HOWEVER

I don’t think this is a great use of macros. The generated AST doesn’t change significantly based on the value of key. Consider using plain old functions:

defmodule UpdateCaster do
  def update_cast({:error, _} = error, _key, _fun), do: error
  def update_cast(data, key, fun) do
    case wrap_fun(fun).(data[key]) do
      {:ok, new_value} ->
        Map.put(data, key, new_value)

      {:error, _} = error ->
        error
    end
  end

  defp wrap_fun(fun) do
    fn
      s when is_binary(s) -> fun.(s)
      anything_else -> anything_else
    end
  end
end

In use, it would look like:

defmodule SomeController do

  def index(conn, params) do
    parsed_params =
      params
      |> UpdateCaster.update_cast("date1", &cast_date/1)
      |> UpdateCaster.update_cast("number_field", &cast_number/1)

    # parsed_params is either a Map or {:error, something}

  end

  defp cast_date(input) do
    case DateTime.from_iso8601(input) do
      {:ok, parsed, _} -> {:ok, parsed}
      {:error, _} = error -> error
    end
  end

  defp cast_number(input) do
    {:ok, String.to_integer(input)}
  end
end

HOWEVER

I’d also recommend considering Ecto.Changeset and its “schemaless changesets” feature; it already does most of the casts you’d be interested in, and will be easier for future readers of your code to follow.

2 Likes

Hmm interesting module… Surely will take a closer look at it… Thanks :slight_smile:

That is what I have not considered at all… I thought that the module would get created “first” but when I think about it now I see why as it could be written as:
defmodule(everything_inside_do_block())
Or can it be written as: defmodule(&everything_inside_do_block/1), so the module could get partially created for it to pass some context info to the do block?

But the difference in AST between the 2 approaches is interesting to say the least.

Hmm interesting module… Surely will take a closer look at it… Thanks :slight_smile:

I was afraid that the do block could not have “array” of ASTs… Okay, thanks for pointing it out…

Wow, had no idea about the “schemaless” way of Ecto’s changesets… This is really valuable…
This is the only thing (because of its decision to be code-first only) I like about Ecto… Thank you.