Simple macro to generate dynamic fragment for filtering jsonb column

Hi,

I want to write a simple macro to generate a dynamic fragment.

defmodule MyApp.Spike do
  import Ecto.Query, warn: false
  
  defmacro filter_address_fragment(field_name, value) do
    field_name_str = to_string(field_name)
    sql = "#{field_name_str}->>'name' ilike ?"

    quote do
      fragment(unquote(sql), unquote(value))
    end
  end

  def filter_address(query, field_name, value) do
    where(query, [p], filter_address_fragment(field_name, value))
  end
end

When I try to compile the app I get the following error.

== Compilation error in file lib/spike.ex ==
** (Protocol.UndefinedError) protocol String.Chars not implemented for {:field_name, [line: 14], nil} of type Tuple
    (elixir 1.14.4) lib/string/chars.ex:3: String.Chars.impl_for!/1
    (elixir 1.14.4) lib/string/chars.ex:22: String.Chars.to_string/1
    (stdlib 4.3.1) erl_eval.erl:748: :erl_eval.do_apply/7
    (stdlib 4.3.1) erl_eval.erl:492: :erl_eval.expr/6
    (stdlib 4.3.1) erl_eval.erl:136: :erl_eval.exprs/6
    expanding macro: MyApp.Spike.filter_address_fragment/2
    lib/spike.ex:14: MyApp.Spike.filter_address/3
    (ecto 3.11.2) expanding macro: Ecto.Query.where/3

However, when I removed filter_address function the code compiles just fine. What did I do wrong?

Removing the function also removes the attempt of the compiler to expand your macro. But the error is in your macro. Your macro is trying to call to_string() (related to the String.Chars protocol in the error message) on an AST node you’re passing in, which doesn’t work. That’s how macro’s work, the arguments are passed in as AST nodes, which you can transform or use somehow.

What you’re trying to accomplish probably doesn’t require a macro at all. A plain function suffices. Either just build up the where clause in your function. Or if you are aiming for reuse of the condition, take a look at Ecto.Query.dynamic/2 (a macro!) for more inspiration.

1 Like

Thank @linusdm I managed to utilise dynamic

  def filter_address(query, field_name, value) do
    where(query, [p], ^dynamic([p], fragment("?->>'name' ilike ?", field(p, ^field_name), ^value)))
  end

 # usage 
(from p in Post, limit: 1) |> filter_address(:source, "Joe%") |> MyApp.Repo.one()

Great! Is the use of dynamic required? I could imagine you can use the fragment directly, without wrapping in dynamic.

1 Like

Fragments cannot be defined with functions only expanded with macros, this is one of the reason dynamic exists in the first place.

2 Likes