lib/ex_teal/resource/index.ex

defmodule ExTeal.Resource.Index do
  @moduledoc """
  Behavior for handling index requests for a given resource
  """
  use ExTeal.Resource.Repo

  alias Ecto.Association.ManyToMany
  alias ExTeal.{Field, FieldFilter}
  alias ExTeal.Fields.BelongsTo
  alias ExTeal.Resource.{Fields, Index, Records}

  import Ecto.Query

  @doc """
  Returns the models to be represented by this resource.

  Default implementation is the result of the ExTeal.Resource.Records.records/2
  callback. Usually a module or an `%Ecto.Query{}`.

  The results of this callback are passed to the filter and sort callbacks before the query is executed.

  `handle_index/2` can alternatively return a conn with any response/body.

  Example custom implementation:

      def handle_index(conn, _params) do
        case conn.assigns[:current_user] do
          nil  -> App.Post
          user -> User.own_posts(user)
        end
      end

  In most cases ExTeal.Resource.Records.records/1, filter/4, and sort/4 are the
  better customization hooks.
  """
  @callback handle_index(Plug.Conn.t(), map) :: Plug.Conn.t() | ExTeal.records()

  @doc """
  Returns the models to be represented by this resource via a relationship.

  Default implementation is the result of the ExTeal.Resource.Records.records/2
  callback returning an ecto query with the fields necessary to display the title.

  `handle_related/2` can alternatively return a conn with any response/body.

  Example custom implementation:

      def handle_related(conn, _params) do
        case conn.assigns[:current_user] do
          nil  -> App.Post
          user -> User.own_posts(user)
        end
      end
  """
  @callback handle_related(Plug.Conn.t(), map) :: Plug.Conn.t() | ExTeal.records()

  @doc """
  Defines the search adapter to use while building a search query:

  Defaults to the `ExTeal.Search.SimpleSearch` module
  """
  @callback search_adapter(Ecto.Query.t(), module(), map()) :: Ecto.Query.t()

  @doc """
  Returns the actions available for a resource

  Default implementation is an empty array.
  """
  @callback actions(Plug.Conn.t()) :: [module]

  def call(resource, conn) do
    conn
    |> resource.handle_index(conn.params)
    |> Records.preload(resource)
    |> Index.with_pivot_fields(conn.params, resource)
    |> Index.filter_via_relationships(conn.params)
    |> Index.field_filters(conn, resource)
    |> Index.sort(conn, resource)
    |> Index.search(conn.params, resource)
    |> execute_query(conn, resource, :index)
    |> resource.render_index(resource, conn)
  end

  def query_for_related(resource, conn) do
    conn
    |> resource.handle_related(conn.params)
    |> Records.preload(resource)
    |> Index.query_by_related(conn.params, resource)
    |> Index.search(conn.params, resource)
    |> Index.sort(conn.params, resource)
    |> execute_query(conn, resource, :related)
    |> resource.render_related(resource, conn)
  end

  defmacro __using__(_) do
    quote do
      use ExTeal.Resource.Repo
      use ExTeal.Resource.Records
      use ExTeal.Resource.Fields

      alias ExTeal.Resource.Pagination
      alias ExTeal.Resource.Serializer
      alias ExTeal.Search.SimpleSearch
      alias Phoenix.Controller

      @behaviour ExTeal.Resource.Index

      def handle_index_query(conn, query), do: Pagination.paginate_query(query, conn, __MODULE__)

      def handle_related_query(_conn, query), do: repo().all(query)

      def render_index(models, resource, conn),
        do: Serializer.render_index(models, resource, conn)

      def render_related(models, resource, conn),
        do: Serializer.render_related(models, resource, conn)

      def handle_index(conn, _params), do: records(conn, __MODULE__)

      def handle_related(conn, _params), do: records(conn, __MODULE__)

      def actions(_conn), do: []

      def actions_for(conn) do
        conn
        |> actions()
        |> Enum.map(fn action_module -> action_module.build_for(conn) end)
      end

      def search_adapter(query, resource, params),
        do: SimpleSearch.build(query, resource, params)

      defoverridable(
        actions: 1,
        actions_for: 1,
        handle_index: 2,
        handle_related: 2,
        search_adapter: 3
      )
    end
  end

  def field_filters(query, %{params: %{"field_filters" => filters}} = conn, resource) do
    with {:ok, filters} <- filters |> :base64.decode() |> Jason.decode(),
         false <- Enum.empty?(filters) do
      FieldFilter.query(query, filters, resource, conn)
    else
      _ -> query
    end
  end

  def field_filters(query, _conn, _resource), do: query

  def sort(
        query,
        %{
          params:
            %{
              "order_by" => field,
              "order_by_direction" => dir,
              "relationship_type" => "ManyToMany"
            } = params
        },
        resource
      ) do
    fields = Fields.all_fields(resource)
    field = Enum.find(fields, &(&1.attribute == field))

    case field do
      nil -> sort_by_pivot(query, params, resource)
      _ -> handle_sort(query, field, String.to_existing_atom(field.attribute), dir)
    end
  end

  def sort(query, %{params: %{"order_by" => field, "order_by_direction" => dir}} = conn, resource)
      when not is_nil(field) and not is_nil(dir) do
    new_schema = struct(resource.model(), %{})

    field_struct =
      resource
      |> Fields.index_fields(conn)
      |> Enum.find(&(&1.attribute == field))
      |> then(& &1.type.apply_options_for(&1, new_schema, %{}, :index))

    handle_sort(query, field_struct, String.to_existing_atom(field), dir)
  end

  def sort(query, _params, resource) do
    case resource.sortable_by() do
      field when not is_nil(field) -> from(q in query, order_by: ^String.to_existing_atom(field))
      _ -> from(q in query, order_by: ^resource.default_order())
    end
  end

  defp handle_sort(query, %Field{type: BelongsTo} = field, _field_key, dir) do
    handle_sort(query, nil, field.options.belongs_to_key, dir)
  end

  defp handle_sort(query, %Field{virtual: true}, f, "asc") do
    order_by(query, [q], asc: fragment("?", literal(^"#{f}")))
  end

  defp handle_sort(query, %Field{virtual: true}, f, "desc") do
    order_by(query, [q], desc: fragment("?", literal(^"#{f}")))
  end

  defp handle_sort(query, _, field, "asc"), do: order_by(query, [q], asc: field(q, ^field))
  defp handle_sort(query, _, field, "desc"), do: order_by(query, [q], desc: field(q, ^field))
  defp handle_sort(query, _, _, _), do: query

  defp sort_by_pivot(query, params, _resource) do
    with {:ok, field} <- Map.fetch(params, "order_by"),
         {:ok, dir} <- Map.fetch(params, "order_by_direction"),
         field_atom <- String.to_existing_atom(field),
         dir_atom <- String.to_existing_atom(dir) do
      order_by(query, [q, x], [{^dir_atom, field(x, ^field_atom)}])
    end
  end

  def search(query, params, resource) do
    case Map.get(params, "search") do
      nil ->
        query

      "" ->
        query

      _ ->
        resource.search_adapter(query, resource, params)
    end
  end

  def query_by_related(query, %{"first" => "true", "current" => id}, _resource)
      when id != "" do
    query
    |> limit(1)
    |> where([q], q.id == ^id)
  end

  def query_by_related(query, _, _), do: query

  def with_pivot_fields(
        query,
        %{
          "via_resource" => resource_name,
          "via_resource_id" => resource_id,
          "via_relationship" => rel_name,
          "relationship_type" => "ManyToMany"
        },
        _resource
      ) do
    with {:ok, resource} <- ExTeal.resource_for(resource_name),
         {:ok, resource_assoc} <- schema_assoc_for(resource, rel_name) do
      resource_field =
        Enum.find(resource.fields(), &(&1.field == String.to_existing_atom(rel_name)))

      pivot_query(query, resource_assoc, resource_id, resource_field)
    end
  end

  def with_pivot_fields(query, _params, _resource) do
    query
  end

  def filter_via_relationships(query, %{
        "relationship_type" => "ManyToMany"
      }) do
    query
  end

  def filter_via_relationships(
        query,
        %{
          "via_resource" => resource_name,
          "via_resource_id" => resource_id,
          "via_relationship" => rel_name
        }
      )
      when resource_name != "" and resource_id != "" and rel_name != "" do
    with {:ok, resource} <- ExTeal.resource_for(resource_name),
         {:ok, relationship} <- schema_assoc_for(resource, rel_name) do
      reversed_query(relationship, query, resource_id, resource)
    end
  end

  def filter_via_relationships(query, _params), do: query

  def reversed_query(%Ecto.Association.HasThrough{} = rel, query, resource_id, related_resource) do
    related_schema = related_resource.record(nil, resource_id)
    related = repo().preload(related_schema, rel.field)
    ids = related |> Map.get(rel.field) |> Enum.map(& &1.id)
    from(q in query, where: q.id in ^ids)
  end

  def reversed_query(relationship, query, resource_id, _related_resource) do
    from(query, where: ^[{relationship.related_key, resource_id}])
  end

  defp schema_assoc_for(resource, rel_name) do
    associations = resource.model().__schema__(:associations)
    rel = String.to_existing_atom(rel_name)

    case Enum.member?(associations, rel) do
      false ->
        {:error, :not_found}

      true ->
        {:ok, resource.model().__schema__(:association, rel)}
    end
  end

  defp pivot_query(
         query,
         %ManyToMany{join_through: join_through} = assoc,
         resource_id,
         many_to_many_field
       )
       when is_bitstring(join_through) do
    query
    |> join_pivot_and_filter(assoc, resource_id)
    |> customize_many_to_many_query(many_to_many_field, assoc, resource_id)
  end

  defp pivot_query(query, assoc, resource_id, many_to_many_field) do
    query
    |> join_pivot_and_filter(assoc, resource_id)
    |> customize_many_to_many_query(many_to_many_field, assoc, resource_id)
  end

  defp customize_many_to_many_query(
         query,
         %Field{private_options: %{index_query_fn: index_fn}},
         assoc,
         resource_id
       ) do
    index_fn.(query, assoc, resource_id)
  end

  defp customize_many_to_many_query(query, _field, %ManyToMany{join_through: assoc}, _)
       when is_bitstring(assoc),
       do: query

  defp customize_many_to_many_query(query, _, _, _),
    do: select(query, [q, x], %{_row: q, _pivot: x, pivot: true})

  defp join_pivot_and_filter(query, assoc, resource_id) do
    [{rel_1, _rel_2}, {pivot_id, id}] = assoc.join_keys

    from(
      q in query,
      left_join: x in ^assoc.join_through,
      on: field(x, ^pivot_id) == field(q, ^id),
      where: field(x, ^rel_1) == ^String.to_integer(resource_id)
    )
  end

  @doc false
  def execute_query(%Plug.Conn{} = conn, _conn, _resource, _type), do: conn
  def execute_query(results, _conn, _resource, _type) when is_list(results), do: results

  def execute_query(query, conn, resource, :related),
    do: resource.handle_related_query(conn, query)

  def execute_query(query, conn, resource, :index), do: resource.handle_index_query(conn, query)
end