Current section

Files

Jump to
query_builder lib assoc_list.ex
Raw

lib/assoc_list.ex

defmodule QueryBuilder.AssocList do
@moduledoc false
defmodule State do
@moduledoc false
# The `token` function below received way too many arguments (which made the code
# harder to read and led `mix format` to split the arguments over multiple lines).
#
# The purpose of this struct is to reduce the number of arguments and to maintain
# state between `token`'s recursive calls, hence its name.
defstruct source_binding: nil,
source_schema: nil,
# `bindings` allows to keep track of all the binding names in order to
# detect a binding name that is going to be used twice when joining
# associations; in such case, the `token` function raises an error.
bindings: []
end
@doc ~S"""
The purpose of the `token/2` function is to generate a data structure containing
information about given association tree.
It receives a query and a list (with nested lists) of association fields (atoms).
For example:
```
[
{:authored_articles,
[
:article_likes,
:article_stars,
{:comments, [:comment_stars, comment_likes: :user]}
]},
:published_articles
]
```
For each association field, a map will be created with the following keys and values:
* `:assoc_binding`: *named binding* to be used (atom)
* `:assoc_field`: field name (atom)
* `:assoc_schema`: module name of the schema (atom)
* `:cardinality`: cardinality (atom `:one` or `:many`)
* `:has_joined`: indicating whether the association has already been joined or not
with Ecto query (boolean)
* `:nested_assocs`: the nested associations (list)
* `:source_binding`: *named binding* of the source schema (atom)
* `:source_schema`: module name of the source schema (atom)
* `join_type`: `:left` or `inner` (atom)
* `join_filters`: only in case of a left join, clauses for the `:on` option (list of
two keyword lists – and/or clauses)
* `preload`: is to be preloaded or not (boolean)
This information allows the exposed functions such as `QueryBuilder.where/3` to join
associations, refer to associations, etc.
"""
def build(source_schema, assoc_list, assoc_fields, opts \\ []) do
state = %State{
# the name of the binding of the query's root schema is the schema itself
source_binding: source_schema,
source_schema: source_schema,
bindings: [source_schema]
}
assoc_list
|> do_build(List.wrap(assoc_fields), state, opts)
|> merge_assoc_data()
end
defp merge_assoc_data(assoc_list) do
Enum.reduce(assoc_list, [], fn assoc_data, new_assoc_list ->
new_assoc_list
|> Enum.with_index()
|> Enum.find(fn {acc_assoc_data, _index} -> acc_assoc_data.assoc_binding == assoc_data.assoc_binding end)
|> case do
{acc_assoc_data, index} ->
nested_assocs = merge_assoc_data(acc_assoc_data.nested_assocs ++ assoc_data.nested_assocs)
join_type =
cond do
acc_assoc_data.join_type == assoc_data.join_type ->
assoc_data.join_type
acc_assoc_data.join_type == :left || assoc_data.join_type == :left ->
:left
true ->
:inner
end
preload = acc_assoc_data.preload || assoc_data.join_type
join_filters =
acc_assoc_data.join_filters ++ assoc_data.join_filters
|> Enum.uniq()
|> Enum.reject(&(&1 == []))
new_assoc_data =
acc_assoc_data
|> Map.put(:nested_assocs, nested_assocs)
|> Map.put(:join_type, join_type)
|> Map.put(:join_filters, join_filters)
|> Map.put(:preload, preload)
List.replace_at(new_assoc_list, index, new_assoc_data)
nil ->
[assoc_data | new_assoc_list]
end
end)
end
defp do_build(assoc_list, [], _, _), do: assoc_list
defp do_build(assoc_list, [{assoc_field, nested_assoc_fields} | tail], state, opts) do
%{
source_binding: source_binding,
source_schema: source_schema,
bindings: bindings
} = state
{join_type, join_filters} =
case Keyword.get(opts, :join, :inner) do
:left ->
if nested_assoc_fields == [] do
join_filters =
case Keyword.get(opts, :join_filters, []) do
[[], []] -> []
join_filters -> join_filters
end
{:left, List.wrap(join_filters)}
else
{:inner, []}
end
join_type ->
{join_type, []}
end
preload = Keyword.get(opts, :preload, false)
authorizer = Keyword.get(opts, :authorizer, nil)
assoc_data = assoc_data(source_binding, source_schema, assoc_field, join_type, preload, join_filters, authorizer)
%{
assoc_binding: assoc_binding,
assoc_schema: assoc_schema
} = assoc_data
state = %{state | bindings: [assoc_binding | bindings]}
assoc_data =
%{
assoc_data
| nested_assocs:
do_build([], List.wrap(nested_assoc_fields), %{
state
| source_binding: assoc_binding,
source_schema: assoc_schema
}, opts)
}
do_build([assoc_data | assoc_list], tail, state, opts)
end
defp do_build(assoc_list, [assoc_field | tail], state, opts) do
do_build(assoc_list, [{assoc_field, []} | tail], state, opts)
end
defp assoc_data(source_binding, source_schema, assoc_field, join_type, preload, join_filters, authorizer) do
assoc_schema = assoc_schema(source_schema, assoc_field)
cardinality = assoc_cardinality(source_schema, assoc_field)
assoc_binding =
with assoc_binding when not is_nil(assoc_binding) <- source_schema._binding(assoc_field) do
assoc_binding
else
_ -> assoc_schema._binding()
end
{join_type, auth_z_join_filters} =
case authorizer && authorizer.reject_unauthorized_assoc(source_schema, {assoc_field, assoc_schema}) do
%{join: join, on: on, or_on: or_on} ->
{cond do
join == :left || join_type == :left -> :left
true -> :inner
end, [List.wrap(on), [or: List.wrap(or_on)]]}
%{join: join, on: on} ->
{cond do
join == :left || join_type == :left -> :left
true -> :inner
end, [List.wrap(on), [or: []]]}
nil ->
{join_type, []}
end
join_filters =
[join_filters] ++ [auth_z_join_filters]
|> Enum.reject(&(&1 == []))
%{
assoc_binding: assoc_binding,
assoc_field: assoc_field,
assoc_schema: assoc_schema,
cardinality: cardinality,
has_joined: false,
join_type: join_type,
join_filters: join_filters,
preload: preload,
nested_assocs: [],
source_binding: source_binding,
source_schema: source_schema
}
end
defp assoc_schema(source_schema, assoc_field) do
assoc_data = source_schema.__schema__(:association, assoc_field)
if assoc_data do
assoc_data.queryable
else
raise "association :" <>
to_string(assoc_field) <> " not found in " <> to_string(source_schema)
end
end
defp assoc_cardinality(source_schema, assoc_field) do
source_schema.__schema__(:association, assoc_field).cardinality
end
end