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/lib/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
__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
quotein 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
If you register the attribute with persist: true you can get at those attributes after compilation using <Module>.__info__(:attributes)
kip
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
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
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









