How to inject macro functions at end of module

I’m using the __using__ macro to define some boilerplate code. One of the functions defined in the macro is a different arity of a function defined in the module that uses the macro. Here’s an example:

defmodule Obramax.ChannelsFactory do
  use Obramax.FactoryUtils
  alias Obramax.Channels.Channel

  def build(:channel) do
    %Channel{}
    |> struct(attrs_for(:channel))
  end
end

defmodule Obramax.FactoryUtils do
  defmacro __using__(_) do
    quote do
      def build(factory_name, attributes \\ []) do
        factory_name
        |> build()
        |> struct(attributes)
      end
    end
  end
end

I can’t get this to work because the macro will inject build/2 into ChannelsFactory before it has defined build/1, this throwing a CompileError: ** (CompileError) test/support/factories/channels_factory.ex:14: def build/1 conflicts with defaults from build/2

If I move use FactoryUtils to the bottom of ChannelsFactory I can get it to work, but that seems strange and might the code ambiguous.

Are there other solutions?

You can find all you need in Module docs: Compile callbacks.

1 Like

Thanks for the tip. I saw the compilation hooks. I changed FactoryUtils to use @before_compile.

defmodule Obramax.FactoryUtils do
  defmacro __using__(_) do
    quote do
      @before_compile Obramax.FactoryUtils
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      def build(factory_name, attributes \\ []) do
        factory_name
        |> build()
        |> struct(attributes)
      end
    end
  end
end

And I didn’t have to change anything in ChannelsFactory.

defmodule Obramax.ChannelsFactory do
  use Obramax.FactoryUtils
  alias Obramax.Channels.Channel

  def build(:channel) do
    %Channel{}
    |> struct(attrs_for(:channel))
  end
end

Not sure if this is the best way, but it works.

I believe that you should add also @behaviour attribute into __using__ macro, so code would look cleaner. For now somebody reading __before_compile__ would not know from where build/1 comes and with behaviour you could require it.

3 Likes

I’m don’t think that helps, unless I’m doing it wrong. I added the behaviour, but I don’t see any compiler warnings when defining modules without build/1.

defmodule Obramax.Factory do
  @callback build(factory_name :: atom) :: struct()

  defmacro __using__(_) do
    quote do
      @before_compile Obramax.Factory
      @behaviour Obramax.Factory
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      alias Obramax.Repo

      def build(factory_name, attributes \\ []) do
        factory_name
        |> build()
        |> struct(attributes)
      end
    end
  end
end

The factory definition is unchanged (but, for my test, I created a factory without a build/1 to see what the compiler did):

defmodule Obramax.ChannelsFactory do
  use Obramax.Factory
  alias Obramax.Channels.Channel

  def build(:channel) do
    %Channel{}
    |> struct(attrs_for(:channel))
  end
end

Presumably, if I define a factory without build/1 I should see a compiler warning, right? That doesn’t happen. I think this is because build/2 with a default argument defined in __before__compile expands to build/1.

Due to the way I defined my functions, if you call build/1 on a factory without having defined it, it falls through to build/2 defined in __before_compile__, which hangs the process.

Maybe it’s better to define a function that raises an error? For example:

defmodule Obramax.Factory do
  defmacro __before_compile__(_env) do
    quote do
      # ...
      def build(factory_name) do
        raise RuntimeError, "Factory `#{factory_name}` not defined."
      end

      def build(factory_name, attributes \\ []) do
        factory_name
        |> build()
        |> struct(attributes)
      end
    end
  end
end

The problem is that you are already defining build/1 without noticing that! :077:

Look that when you write such code in iex:

defmodule Example do
  def sample(first_arg, second_arg \\ nil), do: {first_arg, second_arg}
end

and after it you wrote Example. + hit <Tab> (for printing suggestions) then you would see:

> Example.sample
sample/1    sample/2

Which means you have defined sample/1 as well as sample/2 by just one function definition (using default argument). It’s working like:

defmodule Example do
  def sample(first_arg), do: sample(first_arg, nil)
  def sample(first_arg, second_arg), do: {first_arg, second_arg}
end

So your problem is build/1 function definition inside __before_compile__ - it’s why compiler does not show you any warning. Notice that when you remove default value you should see:

warning: function build/1 required by behaviour Obramax.Factory is not implemented (in module Obramax.ChannelsFactory)

2 Likes

Right, I thought that was happening. But I struck out the text thinking I was wrong. So in this case a behaviour wouldn’t really help, unless I remove the default value from the second argument and make it required…

Yeah or … just rename one function. :smiley:

For example in __before_compile__ you can have full_build/1 and full_build/2 and in behaviour you would have only build/1.

Also notice that build/1 from __before_compile__ conflicts with build/1 from behaviour, so in fact one of them (if I understand it correctly build/1 from behaviour) is never used.

2 Likes

Thanks for clarifying that. I put build/1 in __before_compile__ to throw an error instead of letting the process hang when the function call can’t pattern match. For example, if you use an atom that has no function definition: build(:i_dont_exist)).

And now I know why it hangs. When a build/1 call can’t pattern match, it falls through to build/2. But in reality, it’s falling through to build/1 that’s automatically expanded due to the default argument, which again calls build/2 with the unknown atom as the first argument, and a list as the second argument: build(:i_dont_exist, []).

Then build/2 calls build/1 again (the expanded one, from the default argument):

def build(factory_name, attributes) do
  factory_name
  |> build() # <-- here
  |> struct(attributes)
end

… and the process keeps repeating.

Thanks! This whole thread has been illuminating. I learned a bunch of stuff today!

1 Like