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