Elixir import/2 overrides a previously declared import/2

2020/03/26 12:00am

Elixir has import/2 form that imports some symbols in the current scope from other modules. It overrides previously declared import/2. Let me show an example:

iex(1)> import List
List
iex(2)> first([1])
1
iex(3)> import List, only: [flatten: 1]
List
iex(4)> first([1])                     
** (CompileError) iex:4: undefined function first/1

In the above example, you can see List.first/1 which was available at iex(3) is undefined after import List, only: [flatten: 1] at iex(4).

Postmortem

It’s a real story in my company’s production code. When I was writing a new Phoenix controller, I noticed that there were similar patterns in some controllers:

  1. Fetch the authenticated user’s ID from conn.private (Authentication is implemented in the another plug).
  2. Proceed with the requested action if the user exists in the DB.
  3. Otherwise, return an error response.

I happened to feel like being a good programmer, I began to write a macro to reduce duplicate code.

defmodule AuthUserAction do
  defmacro __using__(_opts) do
    quote do
      import Plug.Conn
      import Phoenix.Controller, only: [json: 2, action_name: 1]

      def action(conn, _) do
        with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
             %User{} = user <- Repo.get(User, user_id) do
          apply(__MODULE__, action_name(conn), [conn, conn.params, user])
        else
          _ ->
            conn
            |> put_status(:forbidden)
            |> json(%{
              error: "forbidden"
            })
        end
      end
    end
  end
end

Previously, a typical controller looks like:

use Phoenix.Controller

def my_action(conn, params) do
  with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
       %User{} = user <- Repo.get(User, user_id) do
    ...
  end
end

It’s verbose and boring. With my new AuthUserAction module, it should turn to be simpler.

use Phoenix.Controller
use AuthUserAction

def my_action(conn, params, user) do
  ...
end

Great! Job done, right?

Compiler Errors

But once I run mix compile, the compiler complained about AuthUserAction:

== Compilation error in file lib/my_app/controllers/my_controller.ex ==
** (CompileError) lib/my_app/controllers/my_controller.ex:1: undefined function put_new_layout/2
    (elixir 1.10.1) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib 3.11.2) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir 1.10.1) lib/kernel/parallel_compiler.ex:233: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

The reason why the compiler couldn’t find the function put_new_layout/2 was import/2 behavior I already described. The line below:

import Phoenix.Controller, only: [json: 2, action_name: 1]

This import/2 overrode the function put_new_layout/2 imported in use Phoenix.Controller.

How to solved it?

Because import/2 is lexical, so we moved it into the action/2 function.

defmodule AuthUserAction do
  defmacro __using__(_opts) do
    quote do
      def action(conn, _) do
        import Plug.Conn
        import Phoenix.Controller, only: [json: 2, action_name: 1]

        ...
      end
    end
  end
end

Now the compiler successfully compiles our code.

By the way, in the real production code, the most of implementation was moved into an ordinary function.

defmodule AuthUserAction do
  import Plug.Conn
  import Phoenix.Controller, only: [json: 2, action_name: 1]

  defmacro __using__(_opts) do
    quote do
      def action(conn, _) do
        AuthUserAction.action(conn, __MODULE__)
      end
    end
  end

  def action(conn, module) do
    with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
         %User{} = user <- Repo.get(User, user_id) do
      apply(module, action_name(conn), [conn, conn.params, user])
    else
      _ ->
        conn
        |> put_status(:forbidden)
        |> json(%{
          error: "forbidden"
        })
    end
  end
end