Is this a good/good use case for a macro?

I wrote my first Elixir macro to make writing parsers for my ex_aws_iam lib less tedious. It’s a thin wrapper around SweetXml. Instead of having to write this:

  def parse(xml, "ListUsers") do
    SweetXml.xpath(xml, ~x"//ListUsersResponse",
      list_users_result: [
        ~x"//ListUsersResult",
        is_truncated: ~x"./IsTruncated/text()"s,
        marker: ~x"./Marker/text()"o,
        users: [
          ~x"./Users/member"l,
          path: ~x"./Path/text()"s,
          user_name: ~x"./UserName/text()"s,
          arn: ~x"./Arn/text()"s,
          user_id: ~x"./UserId/text()"s,
          create_date: ~x"./CreateDate/text()"s
        ]
      ],
      response_metadata: [~x"//ResponseMetadata", request_id: ~x"./RequestId/text()"s]
    )
  end

You would write this:

  defparser(:list_users,
    list_users_result: [
      ~x"//ListUsersResult",
      :is_truncated,
      :marker,
      users: [
        ~x"./Users/member"l,
        :path,
        :user_name,
        :arn,
        :user_id,
        :create_date
      ]
    ],
    response_metadata: [
      ~x"//ResponseMetadata",
      :request_id
    ]
  )

Below is the macro itself:

defmodule ExAws.Iam.TestMacro do
  import SweetXml, only: [sigil_x: 2]

  defmacro defparser(action, fields) do
    action_name = to_camel(action)
    fields = Enum.map(fields, &compile/1)

    quote do
      def parse(xml, unquote(action_name)) do
        SweetXml.xpath(xml, ~x"//#{unquote(xml_path(action_name))}", [
          {unquote(xml_node(action_name)), [~x"//#{unquote(xml_path(action_name))}" | unquote(fields)]}
        ])
      end
    end
  end

  defp xml_path(action), do: action <> "Response"

  defp xml_node(action) do
    action
    |> xml_path() 
    |> Macro.underscore()
    |> String.to_atom()
  end

  defp compile(field) when is_atom(field) do
    quote do
      {unquote(field), ~x"./#{unquote(to_camel(field))}/text()"s}
    end
  end

  defp compile({key, value}) do
    quote do
      {unquote(key), unquote(compile(value))}
    end
  end

  defp compile(list) when is_list(list), do: Enum.map(list, &compile/1)

  defp compile({:sigil_x, _, _} = field), do: field

  defp to_camel(atom) do
    atom
    |> Atom.to_string()
    |> Macro.camelize()
  end
end

It would be nice to pass an atom (:users) instead of a sigil (~x"./Users/member"l) before the desired fields, but I wouldn’t be able to pass the type modifier, like the l above to indicate that the node contains a list.

The same issue applies when passing the fields: I have no way of passing the field type.

      # ...
      users: [
        ~x"./Users/member"l,
        :path,
        :user_name,
        :arn,
        # ...

I can pass the field and its type as a tuple, but this makes things more complicated and then you’d wonder whether the macro adds any value.

Would you consider this a good use case for a macro? Would you write the macro diferently?

1 Like

I prefer the non-macro version for the following reasons:

  • The macro version isn’t much shorter than the non-macro version
  • The macro version isn’t more readable than the non-macro version
  • The macro version has more magic going on so other developers (and your future self) will have a hard time understanding what is going on

A good rule of thumb is: when in doubt, don’t use macros. There are many downsides to meta-programming so unless the macro you build adds a lot of expressiveness or performance gains, it’s usually not worth the tradeoff.

1 Like

I would say no, because the non macro code is shorter and easier to understand.

1 Like