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?