lib/ecto_sparkles.ex

# SPDX-License-Identifier: Apache-2.0
defmodule EctoSparkles do
  import Ecto, only: [assoc: 2]
  import Untangle

  @doc """
  `join_preload` is a helper for preloading associations using joins.

  By default, Ecto preloads associations using a separate query for each association, which can degrade performance.
  You could make it run faster by using a combination of join/preload, but that requires a bit of boilerplate (see example below).

  With `EctoSparkles`, you can accomplish this with just one line of code.

  ## Example using just Ecto

  ```
  import Ecto.Query
  Invoice
  |> join(:left, [i], assoc(i, :customer), as: :customer)
  |> join(:left, [i, c], assoc(c, :account), as: :account)
  |> join(:left, [i], assoc(i, :lines), as: :lines)
  |> preload([lines: v, customers: c, account: a], lines: v, customer: {c, [a: account]})
  |> Repo.all()
  ```

  ## Example using `join_preload`

  ```
  import EctoSparkles
  Invoice
  |> join_preload([:customer, :account])
  |> join_preload([:lines])
  |> Repo.all()
  ```
  """
  defmacro join_preload(query, associations),
    do: join_preload_impl(query, associations)

  defp join_preload_impl(query, associations) do
    root = var(:root)

    cond do
      is_list(associations) ->
        bindings = preload_bindings(associations)
        expr = preload_expr(associations)

        joins(query, associations, [root], :root)
        |> preload_clause(bindings, expr)

      is_atom(associations) ->
        expr = quote do: sparkly in assoc(root, unquote(associations))
        preload = [{associations, associations}]

        rejoin(query, [root], expr, associations)
        |> preload_clause(preload, preload)

      true ->
        raise RuntimeError,
              "join_preload expected an atom or list of atoms, got: #{inspect(associations)}"
    end
  end

  defp joins(query, [], _bindings, _assoc), do: query

  defp joins(query, [j | js], bindings, assoc) do
    bs = bindings ++ [{j, var(j)}]
    condition = quote do: sparkly in assoc(unquote(var(assoc)), unquote(j))

    rejoin(query, bindings, condition, j)
    |> joins(js, bs, j)
  end

  # [a: a], [a: a, b: b], [a: a, b: b, c: c] etc.
  defp preload_bindings(names),
    do: Enum.map(names, &{&1, Macro.var(&1, __MODULE__)})

  # [a: {a, [b: b]}], [a: {a, [b: {b, [c: c]}]}] etc.
  defp preload_expr([last]) when is_atom(last),
    do: [{last, Macro.var(last, __MODULE__)}]

  defp preload_expr([next | rest]) when is_atom(next),
    do: [{next, {Macro.var(next, __MODULE__), preload_expr(rest)}}]

  @doc """
  AKA `join_preload++`. It's more powerful, but it does it with more (and different!) syntax.

  e.g.
  ```
  proload(query, activity: [
    :verb, :boost_count, :like_count, :replied,
    # relations under object will have their aliases prefixed with object_, i.e.
    # :object_message, :object_post, :object_post_content
    # the original names will still be used for the associations.
    object: {"object_", [:message, :post, :post_content]}
  ])
  ```
  """
  defmacro proload(query, qual \\ :left, associations),
    do: proload_impl(query, qual, associations, __CALLER__)

  defp proload_impl(query, qual, associations, caller) do
    # we want to expand metadata references
    associations = List.wrap(expand(associations, caller))
    # iterate over the form, generating nested join clauses
    proload_join(query, qual, associations, [var(:root)], :root, "", caller)
    # pipe that into a preload expression
    |> preload_clause(
      proload_preload_bindings(associations),
      proload_preload_expr(associations)
    )
  end

  # this recurses through the forms generating a join clause at each
  # step, which it pipes the query form through returning a new query form.
  defp proload_join(
         # a quoted form that evaluates to a query
         query,
         # left/inner/etc.
         qual,
         # the current expression we are translating
         form,
         # an improper keyword list of nested bindings for our join expr
         bindings,
         # the alias of the thing we are joining from
         assoc,
         # current string prefix to prepend to generated aliases
         prefix, 
         caller
       ) do
    case form do
      # an atom is a simple join
      _ when is_atom(form) ->
        alia = prefix(form, prefix)

        maybe_rejoin(query, qual, bindings, assoc, form, alia, caller)

      # lists are simply folded over
      _ when is_list(form) ->
        Enum.reduce(
          form,
          query,
          &proload_join(&2, qual, &1, bindings, assoc, prefix, caller)
        )

      # a 2-tuple where the key is a binary extends the prefix
      {pre, form} when is_binary(pre) ->
        proload_join(query, qual, form, bindings, assoc, prefix <> pre, caller)

      # a 2-tuple where the key is an atom names an association
      {rel, form} when is_atom(rel) ->
        alia = prefix(rel, prefix)
        # now generate a join, aliasing it with a prefix
        maybe_rejoin(query, qual, bindings, assoc, rel, alia, caller)
        # and recurse generating the rest of the joins
        |> proload_join(
          qual,
          # the nested bit
          form,
          # add our alias to the bindings
          bindings ++ [{alia, var(alia)}],
          # join from us
          alia,
          # pass the prefix through
          prefix, 
          caller
        )

      {:^, _, _} ->
        query

      _ ->
        raise RuntimeError,
              "proload expected an atom, list or 2-tuple, got: #{inspect(form)}"
    end
  end

  defp maybe_rejoin(query, qual, bindings, assoc, rel, alia, caller) do
    # debug(query)
    # debug(bindings)
    # debug(assoc)
    # debug(rel)
    # TODO: only join assocs that exist
    # assoc_exists?(query, assoc, rel, caller)
    # |> debug()

    # if schema.__schema__(:association, unquote(rel))
    expr = quote(do: sparkly in assoc(unquote(var(assoc)), unquote(rel)))
    rejoin(query, qual, bindings, expr, alia)
  end

  # figures out the list of bindings to supply to preload. this will
  # include all aliases generated by the specification
  defp proload_preload_bindings(form) do
    # get a list of all relevant aliases
    proload_aliases(form)
    # for all the good that it will do, try and minimise duplication
    |> Enum.dedup()
    # turn the names into bindings
    |> Enum.map(&{&1, var(&1)})
  end

  # recursively get a list of all aliases (with prefixes correctly applied)
  defp proload_aliases(form, prefix \\ "") do
    case form do
      _ when is_atom(form) ->
        [prefix(form, prefix)]

      _ when is_list(form) ->
        Enum.flat_map(form, &proload_aliases(&1, prefix))

      {pre, form} when is_binary(pre) ->
        proload_aliases(form, prefix <> pre)

      {rel, form} when is_atom(rel) ->
        [prefix(rel, prefix) | proload_aliases(form, prefix)]
      
      {:^, _, _} ->
        []
    end
  end

  # generates a preload expression from a specification. the structure is mostly the same,
  # it's really just intercepting prefix tuples and generating aliases.
  defp proload_preload_expr(form, prefix \\ "") do
    case form do
      _ when is_atom(form) ->
        [{form, var(prefix(form, prefix))}]

      _ when is_list(form) ->
        Enum.map(form, &proload_preload_expr(&1, prefix)) 

      {pre, form} when is_binary(pre) ->
        proload_preload_expr(form, prefix <> pre)

      {rel, form} when is_atom(rel) ->
        {rel, {var(prefix(rel, prefix)), proload_preload_expr(form, prefix)}}

      {:^, _, _} ->
        IO.warn("TODO: support preload with pinned function or query: https://hexdocs.pm/ecto/Ecto.Query.html#preload/3-preload-queries, got: #{inspect(form)}")
        form
    end
  end

  @doc """
  `reusable_join` is similar to `Ecto.Query.join/{4,5}`, but can be called multiple times with the same alias.

  Note that only the first join operation is performed, the subsequent ones that use the same alias
  are just ignored. Also note that because of this behaviour, it is mandatory to specify an alias when
  using this function.

  This is helpful when you need to perform a join while building queries one filter at a time,
  because the same filter could be used multiple times or you could have multiple filters that
  require the same join, which poses a problem with how the `filter/3` callback work, as you
  need to return a dynamic with the filtering, which means that the join must have an alias,
  and by default Ecto raises an error when you add multiple joins with the same alias.

  To solve this, it is recommended to use this macro instead of the default `Ecto.Query.join/{4,5}`,
  in which case there will be only one join in the query that can be reused by multiple filters.
  """
  defmacro reusable_join(query, qual \\ :left, bindings, expr, opts) do
    as = Keyword.fetch!(opts, :as)
    reusable_join_impl(query, qual, bindings, expr, opts, as)
  end

  # i don't think this needs to be public anymore, but it doesn't hurt
  @doc false
  def reusable_join_impl(query, qual \\ :left, bindings, expr, opts, as) do
    args = [qual, bindings, expr, opts]

    quote do
      query = Ecto.Queryable.to_query(unquote(query))

      if Enum.any?(query.joins, &(&1.as == unquote(as))),
        do: query,
        else: join(query, unquote_splicing(args))
    end
  end

  # slightly more do-what-i-mean interface to reusable_join_impl
  defp rejoin(query, bindings, expr, opts) when is_list(opts),
    do: rejoin(query, :left, bindings, expr, opts, Keyword.fetch!(opts, :as))

  defp rejoin(query, bindings, expr, as) when is_atom(as),
    do: rejoin(query, :left, bindings, expr, [as: as], as)

  # not currently used, but handy
  defp rejoin(query, qual, bindings, expr, opts)
       when is_atom(qual) and is_list(opts),
       do: rejoin(query, qual, bindings, expr, opts, Keyword.fetch!(opts, :as))

  defp rejoin(query, qual, bindings, expr, as)
       when is_atom(qual) and is_atom(as),
       do: rejoin(query, qual, bindings, expr, [as: as], as)

  defp rejoin(query, qual, bindings, expr, opts, as),
    do: reusable_join_impl(query, qual, bindings, expr, opts, as)

  # expands aliases and metadata recursively
  defp expand(form, env) do
    case form do
      {:@, _, _} ->
        Macro.expand(form, env)

      {:__aliases__, _, _} ->
        Macro.expand(form, env)

      {k, meta, args} when is_list(args) ->
        {k, meta, Enum.map(args, &expand(&1, env))}

      {k, v} ->
        {expand(k, env), expand(v, env)}

      _ when is_list(form) ->
        Enum.map(form, &expand(&1, env))

      _ ->
        form
    end
  end

  # generates an ecto preload clause
  defp preload_clause(query, bindings, expr),
    do: quote(do: Ecto.Query.preload(unquote(query), unquote(bindings), unquote(expr)))

  # creates a var private to this module
  defp var(name) do
    # IO.warn(inspect name)
    Macro.var(name, __MODULE__)
  end

  # applies the current prefix for projoin
  defp prefix(x, y) when is_atom(x), do: prefix(Atom.to_string(x), y)
  defp prefix(x, y) when is_binary(x), do: String.to_atom(y <> x)



end