Help with macro - how to inject a sort function into my module?

I would like to understand how to simple inject a sort function into my module.

for key <- @query_keys, direction <- [asc: "asc", desc: "desc"] do

        {atom_dir, str_dir} = direction

        arg_name = "#{key}_#{str_dir}"

        def unquote(:sort)(q, arg_name) do

          # I can't get atom_dir or direction or key here!! 

          order_by(q, [{^var!(atom_dir), :s}])

        end

I would like to generate something like that:

def sort(q, "name_asc"), do: order_by(q, [account: a], asc: a.name)

no need to use var!, just plain unquote() in this case.

defmodule X do
  for key <- [:a, :b, :c],
      {atom_dir, str_dir} <- [asc: "asc", desc: "desc"] do
    arg_name = "#{key}_#{str_dir}"

    def sort(q, unquote(arg_name)) do
      order_by(q,
        key: unquote(key),
        atom_dir: unquote(atom_dir),
        str_dir: unquote(str_dir),
        arg_name: unquote(arg_name)
      )
    end
  end

  defp order_by(arg1, arg2), do: {arg1, arg2}
end

iex(1)> X.sort(1, "a_desc")
{1, [key: :a, atom_dir: :desc, str_dir: "desc", arg_name: "a_desc"]}

Is this what you are after?

This does not work either…

undefined function arg_name/0

Dynamic sort function is not able to see variables above…

I ran the code above and it worked. That is weird.

Actually, this is pretty much how the Unicode module works.

It’s not working for me. but thank you anyway.

You can upload a new Elixir project with this code only that I pasted, including the Elixir version you are running. I can have a look at it

defmacro __before_compile__(_opts) do

    schema = parse_module_schema(__CALLER__.module)

    quote do

      Module.put_attribute(

        __MODULE__,

        :query_keys,

        Enum.map(Module.get_attribute(__MODULE__, :ecto_fields), fn {k, v} ->

          Atom.to_string(k)

        end)

      )

      @spec init_query() :: Ecto.Query.t()

      def init_query, do: from(__MODULE__, as: unquote(schema))

      defoverridable(init_query: 0)

      for key <- @query_keys, {atom_dir, str_dir} <- [asc: "asc", desc: "desc"] do

        atom_key = String.to_existing_atom(key)

        arg_name = "#{key}_#{str_dir}"

        def unquote(:sort)(q, arg_name) do

          order_by(q, [unquote(atom_dir), unquote(atom_key)])

        end

      end

      def sort(q, _), do: order_by(q, [desc: :updated_at])

you are not unquoting arg_name

this way arg_name works… the problem is INSIDE the sort function…

neither atom_dir nor atom_key are available there…

order_by(q, [unquote(atom_dir), unquote(atom_key)])

If you want to generate,
def sort(q, "name_asc")

where arg_name = “name_asc”,
you need to unquote the variable arg_name , otherwise it is just a variable like q

Here is a fully working example:

defmodule Example do
  defmacro __before_compile__(_opts) do
    schema = parse_module_schema(__CALLER__.module)

    quote bind_quoted: [schema: schema] do
      import Ecto.Query, only: [from: 2, order_by: 2]

      @query_keys Enum.map(@ecto_fields, &elem(&1, 0))

      @spec init_query() :: Ecto.Query.t()
      def init_query, do: from(__MODULE__, as: unquote(schema))

      defoverridable(init_query: 0)

      for key <- @query_keys, direction <- [:asc, :desc] do
        arg_name = "#{key}_#{direction}"

        def unquote(:sort)(queryable, unquote(arg_name)) do
          order_by(queryable, [{unquote(direction), unquote(key)}])
        end
      end

      if :updated_at in @query_keys do
        def sort(queryable, _), do: order_by(queryable, desc: :updated_at)
      end
    end
  end

  defp parse_module_schema(module) do
    module |> Module.split() |> List.last() |> Macro.underscore() |> String.to_atom()
  end
end

defmodule Post do
  use Ecto.Schema

  @before_compile Example

  schema "posts" do
    field :author
    field :content
  end
end

iex> Post.sort(Post.init_query(), "content_desc")
#Ecto.Query<from p0 in Post, as: :post, order_by: [desc: p0.content]>

Hint

Any Elixir expression is valid inside the interpolation. If a string is given, the string is interpolated as is. If any other value is given, Elixir will attempt to convert it to a string using the String.Chars protocol.

Source: String — Elixir v1.16.0

This means that:

iex> atom = :sample
iex> "#{atom}"
"sample"

Helpful resources:

  1. Hygiene in imports
  2. Binding and unquote fragments
  3. Kernel.SpecialForms.&/1
  4. Kernel.elem/2
1 Like

I think it was a particular problem here… We were able to solve by using another approach… this normal one simply does not work.

defmacro __before_compile__(_opts) do

    schema = parse_module_schema(__CALLER__.module)

    query_keys =

      __CALLER__.module

      |> Module.get_attribute(:ecto_fields)

      |> Keyword.keys()

      |> Enum.map(&Atom.to_string/1)

    quote location: :keep, bind_quoted: [query_keys: query_keys, schema: schema] do

      @spec init_query() :: Ecto.Query.t()

      def init_query, do: from(__MODULE__, as: unquote(schema))

      defoverridable(init_query: 0)

      Enum.each(query_keys, fn key ->

        def unquote(:sort)(query, "#{unquote(key)}_asc") do

          order_by(query, [{^unquote(schema), e}],

            asc: field(e, ^String.to_existing_atom(unquote(key)))

          )

        end

        def unquote(:sort)(query, "#{unquote(key)}_desc") do

          order_by(query, [{^unquote(schema), e}],

            desc: field(e, ^String.to_existing_atom(unquote(key)))

          )

        end

      end)

      def sort(query, _) do

        order_by(query, [{^unquote(schema), e}], desc: e.updated_at)

      end

Thank you guys… It’s solved.

2 Likes