thiagomajesk

thiagomajesk

How to expose (or use) a module attribute that is built using macros

Hi everyone, I was toying around with macros today and I reached a stagnation point. After trying multiple approaches I started to think that what I’m doing might not be possible, so I’m hoping for some guidance and/ or alternatives.

I’m trying to implement a module that will help me define some counter caches in a table. Here’s the general idea… I have a schema called reactions that stores various kinds of interactions a user might provide for a post:

schema "reactions" do
    field :feeling, Ecto.Enum, values: [:like, :dislike]
    # embeds_one :counter_caches, Cache
end
post_id user_id feeling
1 1 like
1 2 dislike
1 3 like
2 1 like

I expect that for those enum values, the following fields would be generated in the schema: feeling_like_count and feeling_dislike_count. Here’s what I came up with:

defmodule CounterCache do
  import Ecto.Query

  defmacro __using__(_opts) do
    quote do
      import CounterCache

      Module.register_attribute(__MODULE__, :counter_cache_fields, accumulate: true)
    end
  end

  defmacro counter_cache_field(field, opts \\ []) do
    {group, opts} = Keyword.pop(opts, :group)
    {suffix, _opts} = Keyword.pop(opts, :suffix, "count")

    quote do
      name =
        "#{unquote(group)}_#{unquote(field)}_#{unquote(suffix)}"
        |> String.trim_leading("_")
        |> String.to_atom()

      Module.put_attribute(__MODULE__, :counter_cache_fields, {unquote(group), name})
      Ecto.Schema.field(name, :integer, default: 0)
    end
  end

  defmacro counter_cache_field_enum(module, field) do
    quote do
      values = Ecto.Enum.values(unquote(module), unquote(field))
      Enum.each(values, &counter_cache_field(&1, group: unquote(field)))
    end
  end
end

So, the part that I’m stuck at is generating and exposing the query that retrieves the counter cache fields. I expect the query to be something like this:

select
  count(1) filter (where feeling = 'like') as likes
  count(1) filter (where feeling = 'dislike') as dislikes
from reactions
post_id likes dislikes
1 2 1
2 1 0

Here’s what I’ve managed to do so far with this ancillary function:

def __query__(module, fields) do
  quote bind_quoted: [module: module, fields: fields] do
    Enum.reduce(fields, from(module), fn 
      {nil, field}, query -> 
        select(query, [m], filter(count(1), not is_nil(field(m, ^field))))
      {field, value}, query ->  
        select(query, [m], filter(count(1), not is_nil(field(m, ^field) and field(m, ^field) == ^value)))
     end)
  end
end

I was hoping to be able to call a function where I’d pass the source module (where the query will fetch the information) and receive a query that I can use later to update the embed that holds the cached values in the posts table.

Repo.all(Post.counter_cache_query(Reactions))
#=> [
#=>  %{post_id: 1, likes: 2, dislikes: 1}, 
#=>  %{post_id: 2, likes: 1, dislikes: 0}
#=> ]

I had various problems trying to implement this function. I started defining it inside the __using__ macro, and had @counter_cache_fields be empty by the time I tried to generate the query. Also, tried to call the attribute outside the definition and received an error telling me that it cannot be invoked outside of the module.

So, I’m certainly missing something here, I also remembered that Ecto does something similar: ecto/schema.ex at b69d1085cfd491a859f1be36463afcf4838e4891 · elixir-ecto/ecto · GitHub with the @changeset_fields attribute; so I’m not sure exactly what’s the problem. Is what I’m trying to achieve even possible?

Marked As Solved

kip

kip

ex_cldr Core Team

__MODULE__ always refers to the module being compiled. So when __MODULE__ is inside a quote block (assuming the macro returns the quote block), then __MODULE__ is expanded in the calling module. So yes, you are correct. In this case, __CALLER__.module in a macro outside a quote block is the same as __MODULE__ inside a quote block.

In your example, we need to access the caller outside the quote block. Because we need to guarantee that the module attribute is registered and set before the macro code is expanded in the caller. Therefore it needs to be executed in the context of the macro, not the context of the caller. And therefore we need to use __CALLER__.module approach.

Does that help? I find it useful to remember a few simple things about macros:

  • A macro is a function that accepts AST as parameters and is expected to return AST
  • A quote block returns AST and is the most common way to return AST from a macro. But its otherwise not special - you can quote in any code you like at compile time or runtime.
  • A macro is expanded at the time at which it is called. Macro expansion happens recursively until there is nothing left to expand.
  • A call to a macro replaces the macro call with the result of the macro call. It is interpolated at the calling site. And then compilation proceeds normally.
  • A macro is just normal Elixir code except for the requirement to accept AST and return AST so it can execute whatever you want - recognising that this code is executing during macro expansion at compile time, not at runtime.

Also Liked

ityonemo

ityonemo

If you register the attribute with persist: true you can get at those attributes after compilation using <Module>.__info__(:attributes)

kip

kip

ex_cldr Core Team

I realise this may just be an example, but in this example I don’t see what they need to use a module when import will work just as well and is clearer in most cases. Simplifying your example we get:

defmodule Greeter do
  defmacro greeting(name) do
    caller = __CALLER__.module

    # Define the attribute and set it in the calling module
    Module.register_attribute(caller, :greeting, accumulate: false)
    Module.put_attribute(caller, :greeting, name)

    # Code interpolated into the
    # calling site
    quote do
      def greet_inside() do
        @greeting
      end
    end
  end
end

defmodule Gentlemen do
  import Greeter

  greeting "Hello Sir"

  def greet, do: @greeting
end

And in execution:

iex(1)> Gentlemen.greet_inside
"Hello Sir"
iex(2)> Gentlemen.greet       
"Hello Sir"

Hopefully this helps clarify the order of expansion, compilation and execution.

kip

kip

ex_cldr Core Team

Last thought, you might be wondering why this didn’t work:

   defmacro __using__(opts) do
     quote do
       import Greeter
       # This code will be inserted in the calling site and it will
       # be executed at runtime, not compile time because it is
       # in the quote block that will be inserted at the calling site
       Module.register_attribute(__MODULE__, :greeting, accumulate: false)
      
       def greet_inside() do 
         # Using @module_attribute requires that the module
         # attribute exist *at compile time*. But the code above is
         # only executed at runtime. Therefore the reference to 
         # @greeting will fail
         @greeting
       end      
     end
   end 
eksperimental

eksperimental

Just by reviewing your code (I haven’t run any code yet), i can tell a few things.:

  • Enum.each/2 is used for side effects where you don’t expect any value to be returned. It always return :ok. So the second clause of your counter_cache_field macro will always return :ok. You probably are looking for for and make sure it is valid the desired AST what you return.
  1. def __query__(module, fields) do is a function and you are returning a quoted expression. Are you sure about this? I don’t know how you are calling this. Usually you create these helpers and call them within the macro you are building.

  2. I fail to see how you read the stored counter_cache_fields attributes.
    That is why I am asking you for the code

kip

kip

ex_cldr Core Team

In general the pattern for a macro that creates a module attribute in the calling code that is then accessed in the macro is something like this:

defmacro my_macro(opts) do
  # Set up the module and fields from wherever they are derived
  module = Keyword.get(opts, :module)
  fields = Keyword.get(opts, :fields)

  # Register and set the module attribute in the calling module at
  # macro expansion time. At this time the calling module is
  # still being compiled
  calling_module = __CALLER__.module
  Module.register_attribute(calling_module, :counter_cache_fields)
  Module,put_attribute(calling_module, :counter_cache_fields, fields)

  # Now the code that is injected in the caller
  quote do
    defmacro counter_cache_query(module) do
      # CounterCache.__query__(unquote(module), @counter_cache_fields)
    end
  end
end

Where Next?

Popular in Questions Top

srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call th...
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
logicmason
Hi there, I'm working through my first release with elixir/phoenix. I've built a release with distillery and found that it crashes when I...
New
lk-geimfari
What is most correct way to open, read and parse JSON file with poison? For example if we have example.json file in root of some projec...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
Mooodi
Given a string, how can I get access to its character by index? Enum.at("my_string", 2) doesn't work. Or rather, not char, but a substr...
New

Other popular topics Top

chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 30048 115
New
yurko
Here are few pieces of (common) Linux knowledge that we use for reasonably small one server apps. We use Ubuntu but this should work for ...
New
_russellb
I want to try my hand at web scraping. What tools/libraries do I need to use. I’m hoping to turn this into something professional so don’...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New

We're in Beta

About us Mission Statement