Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Passing computed list to an Elixir macro

Tags:

macros

elixir

I have a map that I want to use a single source of truth for a couple of functions. Let's say it is:

source_of_truth = %{a: 10, b: 20}

I'd like the keys of that map to be values of EctoEnum. EctoEnum provides a macro defenum that I should use like this:

  defenum(
    EnumModule,
    :enum_name,
    [:a, :b]
  )

I don't want to repeat [:a, :b] part. I'd like to use the keys from the map instead like this:

  defenum(
    EnumModule,
    :enum_name,
    Map.keys(source_of_truth)
  )

It doesn't work because defenum macro expects a plain list.

I thought I could do it by defining my own macro like this:

 defmacro dynamic_enum(enum_module, enum_name, enum_values) do
   quote do
     defenum(
       unquote(enum_module),
       unquote(enum_name),
       unquote(enum_values)
     )
   end
 end

and then call:

dynamic_enum(EnumModule, :enum_name, Map.keys(source_of_truth))

However, it doest the same thing: enum_values is not a precomputed list but AST for Map.get. My next approach was:

 defmacro dynamic_enum(enum_module, enum_name, enum_values) do
   quote do
     values = unquote(enum_values)
     defenum(
       unquote(enum_module),
       unquote(enum_name),
       ?
     )
   end
 end

Not sure what I could put where the ? is. I can't just put values because it is a variable and not a list. I can't put unquote(values) either.

A solution that sort of works is this one:

defmacro dynamic_enum(enum_module, enum_name, enum_values) do
  {values, _} = Code.eval_quoted(enum_values)
  quote do
    defenum(
      unquote(enum_module),
      unquote(enum_name),
      unquote(values)
    )
  end
end

However, the docs say that using eval_quoted inside a macro is a bad practice.

[EDIT] A solution with Macro.expand does not work either because it doesn't actually evaluate anything. The expansion stops at:

Expanded: {{:., [],
  [
    {:__aliases__, [alias: false, counter: -576460752303357631], [:Module]},
    :get_attribute
  ]}, [],
 [
   {:__MODULE__, [counter: -576460752303357631], Kernel},
   :keys,
   [
     {:{}, [],
      [
        TestModule,
        :__MODULE__,
        0,
        [
          file: '...',
          line: 16
        ]
      ]}
   ]
 ]}

So it does not expand to the list as we expected.

[\EDIT]

What is a good solution for that problem?

like image 940
tkowal Avatar asked Jan 28 '23 01:01

tkowal


2 Answers

As stated in the documentation for Macro.expand/2

The following contents are expanded:

  • Macros (local or remote)
  • Aliases are expanded (if possible) and return atoms
  • Compilation environment macros (__CALLER__/0, __DIR__/0, __ENV__/0 and __MODULE__/0)
  • Module attributes reader (@foo)

Emphasis is mine. So the possibility would be to use module attributes with Macro.expand/2.

  defmacro dynamic_enum(enum_module, enum_name, enum_values) do
    IO.inspect(enum_values, label: "Passed")
    expanded = Macro.expand(enum_values, __CALLER__)
    IO.inspect(expanded, label: "Expanded")

    quote do
      defenum(
        unquote(enum_module),
        unquote(enum_name),
        unquote(expanded)
      )
    end
  end

And call it like:

  @source_of_truth %{a: 10, b: 20}
  @keys Map.keys(@source_of_truth)

  def test_attr do
    dynamic_enum(EnumModuleA, :enum_name_a, @keys)
  end

FWIW, the full code:

$ \cat lib/eenum.ex

defmodule Eenum do
  import EctoEnum

  defmacro dynamic_enum(enum_module, enum_name, enum_values) do
    IO.inspect(enum_values, label: "Passed")
    expanded = Macro.expand(enum_values, __CALLER__)
    IO.inspect(expanded, label: "Expanded")

    quote do
      defenum(
        unquote(enum_module),
        unquote(enum_name),
        unquote(expanded)
      )
    end
  end
end

$ \cat lib/tester.ex

defmodule Tester do
  import Eenum

  @source_of_truth %{a: 10, b: 20}
  @keys Map.keys(@source_of_truth)

  def test_attr do
    dynamic_enum(EnumModuleA, :enum_name_a, @keys)
  end
end

FWIW 2. To be able to call dynamic_enum as shown above from the module scope, all you need is (surprise :) another module scope, already compiled at the moment of macro invocation:

defmodule Defs do
  @source_of_truth %{a: 10, b: 20}
  @keys Map.keys(@source_of_truth)

  defmacro keys, do: Macro.expand(@keys, __CALLER__)
end

defmodule Tester do
  import Defs
  import Eenum

  dynamic_enum(EnumModuleA, :enum_name_a, keys())
end

FWIW 3. The latter (explicit module with definitions) will work even without a necessity to have module attributes:

defmodule Defs do
  defmacro keys, do: Macro.expand(Map.keys(%{a: 10, b: 20}), __CALLER__)
end

defmodule Tester do
  import Defs
  import Eenum

  dynamic_enum(EnumModuleA, :enum_name_a, keys())
end

The rule of thumb is when you find yourself in a need to invoke Code.eval_quoted/3, put this code into the independent module and make compiler invoke this code compilation for you. For functions is works on the module level, for module level it should be put into another module to make module context (aka __CALLER__ and __ENV__) available.

like image 58
Aleksei Matiushkin Avatar answered Jan 29 '23 14:01

Aleksei Matiushkin


I battled with the same problem a while back. Basically you can build your syntax tree in a quote, using unquote to inject your dynamic value, and then use Code.eval_quoted to eval the macros:

options = Map.keys(source_of_truth)

Code.eval_quoted(
  quote do
    EctoEnum.defenum(MyEnum, :type_name, unquote(options))
  end,
  [],
  __ENV__
)
like image 20
Paweł Obrok Avatar answered Jan 29 '23 15:01

Paweł Obrok