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.preload 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