lib/loupe/ecto.ex

if Code.ensure_loaded?(Ecto) do
  defmodule Loupe.Ecto do
    @moduledoc """
    Entrypoint module for Ecto related function with Loupe. Ideally, this module
    should be completely decoupled from any Repo logic and leave that to the app's Repo
    """
    import Ecto.Query
    alias Loupe.Ecto.Context
    alias Loupe.Language
    alias Loupe.Language.GetAst

    @root_binding :root

    @doc "Same as build_query/2 but with context or with implementation with no assigns"
    @spec build_query(binary(), Context.implementation() | Context.t()) ::
            {:ok, Ecto.Query.t(), Context.t()} | {:error, atom()}
    def build_query(string, implementation) when is_atom(implementation) do
      build_query(string, implementation, %{})
    end

    def build_query(string, %Context{} = context) do
      create_query(string, context)
    end

    @doc """
    Builds an Ecto query from either an AST or a string. It requires an implementation
    of the Loupe.Ecto.Definition behaviour and supports assigns as a third parameter.
    """
    @spec build_query(binary(), Context.implementation(), map()) ::
            {:ok, Ecto.Query.t(), Context.t()} | {:error, atom()}

    def build_query(string, implementation, assigns) do
      build_query(string, Context.new(implementation, assigns))
    end

    defp create_query(string, %Context{} = context) do
      with {:ok, %GetAst{} = ast} <- Language.compile(string),
           {:ok, context} <- put_root_schema(ast, context),
           {:ok, context} <- extract_bindings(ast, context) do
        {:ok, to_query(ast, context), context}
      end
    end

    defp extract_bindings(%GetAst{} = ast, %Context{} = context) do
      bindings = GetAst.bindings(ast)
      Context.put_bindings(context, bindings)
    end

    defp put_root_schema(%GetAst{schema: schema}, %Context{} = context) do
      Context.put_root_schema(context, schema)
    end

    defp to_query(%GetAst{} = ast, %Context{root_schema: root_schema} = context) do
      root_schema
      |> from(as: ^@root_binding)
      |> limit_query(ast)
      |> join_relation(context)
      |> filter_query(ast, context)
      |> select_allowed_fields(context)
    end

    defp select_allowed_fields(query, context) do
      case Context.selectable_fields(context) do
        :all -> query
        fields -> select(query, ^fields)
      end
    end

    defp filter_query(query, %GetAst{predicates: predicates}, context) do
      conditions = apply_filter(predicates, context)

      from(query, where: ^conditions)
    end

    defp apply_filter({:or, left, right}, context) do
      left = apply_filter(left, context)
      right = apply_filter(right, context)

      dynamic([_], ^left or ^right)
    end

    defp apply_filter({:and, left, right}, context) do
      left = apply_filter(left, context)
      right = apply_filter(right, context)

      dynamic([_], ^left and ^right)
    end

    defp apply_filter({:not, {operand, binding, value}}, context) do
      binding_path = binding_field(binding, context)
      apply_bounded_filter({:not, {operand, binding_path, value}})
    end

    defp apply_filter({operand, binding, value}, context) do
      binding_path = binding_field(binding, context)
      apply_bounded_filter({operand, binding_path, value})
    end

    defp apply_bounded_filter({:!=, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) != ^unwrap(value))
    end

    defp apply_bounded_filter({:not, {:=, {binding_name, field}, :empty}}) do
      dynamic([{^binding_name, binding}], not is_nil(field(binding, ^field)))
    end

    defp apply_bounded_filter({:=, {binding_name, field}, :empty}) do
      dynamic([{^binding_name, binding}], is_nil(field(binding, ^field)))
    end

    defp apply_bounded_filter({:=, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) == ^unwrap(value))
    end

    defp apply_bounded_filter({:>, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) > ^unwrap(value))
    end

    defp apply_bounded_filter({:<, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) < ^unwrap(value))
    end

    defp apply_bounded_filter({:>=, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) >= ^unwrap(value))
    end

    defp apply_bounded_filter({:<=, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) <= ^unwrap(value))
    end

    defp apply_bounded_filter({:in, {binding_name, field}, value}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) in ^unwrap(value))
    end

    defp apply_bounded_filter({:not, {:in, {binding_name, field}, value}}) do
      dynamic([{^binding_name, binding}], field(binding, ^field) not in ^unwrap(value))
    end

    defp apply_bounded_filter({:not, {:like, {binding_name, field}, value}}) do
      like_value = "%#{unwrap(value)}%"
      dynamic([{^binding_name, binding}], not like(field(binding, ^field), ^like_value))
    end

    defp apply_bounded_filter({:like, {binding_name, field}, value}) do
      like_value = "%#{unwrap(value)}%"
      dynamic([{^binding_name, binding}], like(field(binding, ^field), ^like_value))
    end

    defp binding_field({:binding, [field]}, _context) do
      {:root, String.to_existing_atom(field)}
    end

    defp binding_field({:binding, path}, %Context{bindings: bindings}) do
      [field | rest] = Enum.reverse(path)

      binding =
        Enum.reduce(rest, [], fn step, accumulator ->
          [String.to_existing_atom(step) | accumulator]
        end)

      {Map.fetch!(bindings, binding), String.to_existing_atom(field)}
    end

    defp unwrap({:string, string}), do: string
    defp unwrap({:int, int}), do: int
    defp unwrap({:float, float}), do: float
    defp unwrap({:list, list}), do: Enum.map(list, &unwrap/1)
    defp unwrap(boolean) when is_boolean(boolean), do: boolean

    defp join_relation(query, %Context{bindings: bindings} = context) do
      context
      |> Context.sorted_bindings()
      |> Enum.reduce(query, fn {path, binding}, accumulator ->
        join_spec = parent_binding(bindings, path)

        join_once(accumulator, binding, join_spec)
      end)
    end

    defp join_once(query, binding, {name, parent_binding}) do
      if has_named_binding?(query, binding) do
        query
      else
        join(
          query,
          :left,
          [{^parent_binding, parent}],
          association in assoc(parent, ^name),
          as: ^binding
        )
      end
    end

    defp parent_binding(bindings, path) do
      {top_binding, parent_path} =
        case Enum.reverse(path) do
          [top_binding | top_level_path] -> {top_binding, Enum.reverse(top_level_path)}
        end

      parent_binding =
        case parent_path do
          [] -> @root_binding
          top_level_path -> Map.fetch!(bindings, top_level_path)
        end

      {top_binding, parent_binding}
    end

    defp limit_query(query, %GetAst{quantifier: :all}) do
      query
    end

    defp limit_query(query, %GetAst{quantifier: {:range, {minimum, maximum}}}) do
      limit = maximum - minimum

      query
      |> limit(^limit)
      |> offset(^minimum)
    end

    defp limit_query(query, %GetAst{quantifier: {:int, total}}) do
      limit(query, ^total)
    end
  end
end