defmodule QueryBuilder.Token do @moduledoc false defmodule State do @moduledoc false # @moduledoc """ # 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) This information allows the exposed functions such as `QueryBuilder.where/3` to join associations, refer to associations, etc. """ def token(query, value) do source_schema = QueryBuilder.Utils.root_schema(query) 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 } token([], query, List.wrap(value), state) end defp token(token, _, [], _), do: token defp token(token, query, [assoc_field | tail], state) when is_atom(assoc_field) do %{ source_binding: source_binding, source_schema: source_schema, bindings: bindings } = state assoc_data = assoc_data(query, source_binding, source_schema, assoc_field) %{ assoc_binding: assoc_binding, assoc_schema: assoc_schema, has_joined: has_joined } = assoc_data if has_joined do raise_if_already_bound(bindings, assoc_schema, assoc_binding) end state = %{state | bindings: [assoc_binding | bindings]} token([assoc_data | token], query, tail, state) end defp token(token, query, [{assoc_field, nested_assoc_fields} | tail], state) do %{ source_binding: source_binding, source_schema: source_schema, bindings: bindings } = state assoc_data = assoc_data(query, source_binding, source_schema, assoc_field) %{ assoc_binding: assoc_binding, assoc_schema: assoc_schema, has_joined: has_joined } = assoc_data if has_joined do raise_if_already_bound(bindings, assoc_schema, assoc_binding) end state = %{state | bindings: [assoc_binding | bindings]} assoc_data = %{ assoc_data | nested_assocs: token([], query, List.wrap(nested_assoc_fields), %{ state | source_binding: assoc_binding, source_schema: assoc_schema }) } if !assoc_data.has_joined do raise_if_any_nested_assoc_has_joined(assoc_data.assoc_schema, assoc_data.nested_assocs) end token([assoc_data | token], query, tail, state) end defp assoc_data(query, source_binding, source_schema, assoc_field) 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 has_joined = Ecto.Query.has_named_binding?(query, assoc_binding) %{ assoc_binding: assoc_binding, assoc_field: assoc_field, assoc_schema: assoc_schema, cardinality: cardinality, has_joined: has_joined, 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 defp raise_if_already_bound(bindings, schema, binding) do if Enum.member?(bindings, binding) do raise "trying to bind #{schema} multiple times with same binding name" end end defp raise_if_any_nested_assoc_has_joined(_, []), do: nil defp raise_if_any_nested_assoc_has_joined(schema, [ %{has_joined: true, assoc_schema: nested_schema} | _tail ]) do raise "#{schema} has not been joined while nested #{nested_schema} has joined}" end defp raise_if_any_nested_assoc_has_joined(schema, [ %{has_joined: false, nested_assocs: nested_assocs} | tail ]) do raise_if_any_nested_assoc_has_joined(schema, nested_assocs) raise_if_any_nested_assoc_has_joined(schema, tail) end end