when working on a specific problem I felt the need to have more powerful fragments. Since this gets a bit complicated I will first explain the problem and my solution so the motivation is clear.
Illustration of the problem
In Ecto I love that I can just select structs (and other complex types). This eliminates the need for post-processing functions.
from(p in Post, select: %Post{...})
However, when using array aggregation function from the database, it creates a barrier after which it is not possible use this Ecto feature. I.e. you cannot do something like this
from(p in Post, select: fragment("array_agg(?)", %Post{...}))
So you are left with aggregation some DB-native type, like JSON objects and having a post processing function. I think it's obvious why this is a downgrade compared to the snippet above (if it worked), or something similar.
My solutionTo solve this problem wrote a macro called
cast_agg to easily select and transform structs even after this aggregation barrier. It looks something like this:
from(
p in Post,
join: assoc(c, :comments),
as: :comment,
select: %Post{
...
comments: cast_agg(%Comment{
...
ratings: subquery(from(
r in Rating,
where: r.comment_id == parent_as(:comment).id,
select: cast_agg(%Rating{...})
))
})
},
group_by: [p.id]
)(this also illustrates two typical uses, but more on that later)
The macro
cast_agg is implemented using fragments to Postgres functions
array_agg and
jsonb_build_object. It creates JSON objects corresponding to the constructed structs, but it also stores the information about the struct. So
cast_agg(%Rating{...}) actually returns something like this
{
"_struct_module_name": "Elixir.MyApp.Rating",
"data": [...rating objects data]
}Then I need to convert this back into the struct after the query is executed. This is where I hoped I would be able to hook into the query and do this transformation automatically. But I didn't find any such mechanism. So I ended up with having a generic function that converts these objects back to the struct. So the fetch looks like this:
query
|> Repo.all()
|> transform_cast_agg()
It works but it's not that nice. Having the requirement to run another function after using
cast_agg seems detrimental to the user experience. And it makes it more difficult to create a generic Ecto macro like this.
I feel this transformation is something that could be done internally by Ecto but I wasn't able to do it. It seems to me that remembering that a specific part of the result is a struct and then converting the data into the structs is something that Ecto is capable of. It just seems that I can't hook into this capability as a user.
One solution I envisioned was to be able to append some transformation to a fragment. The transformation would then be used when converting the data back into the Elixir world. Something like this:
defmacro to_atom(field) do
quote do
fragment("lower(?)", unquote(field), &String.to_existing_atom/1)
end
end
This way my
cast_agg macro could store the information about the struct into the query itself and would also do the transformation back into the struct automatically once the query is executed. No need to run additional transformation functions and all would be wrapped in a single macro.
Obviously this proposal is not yet fully refined. Fragments can be used in other places, not just in selects. I also don't know much about how Ecto works internally yet. I just feel like I found something that could be useful. I'd be happy for any feedback.
Why is cast_agg useful
This is a bit of a side note to illustrate why is the example macro
cast_agg useful.
The example query above illustrates why it's handy. You can use it to simply gather associated entities in a single query. The entities can be added via joins or via subqueries (which have to return a single value when used in a select). The macro can be nested in itself allowing to gather even nested associations.
The first case can be solved with an Ecto preload. However, I think this only works for associations, while the
cast_agg macro works for any struct.
The second case is probably more interesting, since it allows you to load the associations via subqueries and avoid joins.