defmodule Incant.Live.Rows do @moduledoc false import Ecto.Query alias Incant.Live.Authorization def list(resource, table_state, context \\ %{}) def list(nil, _table_state, _context), do: [] def list(resource, table_state, context), do: page(resource, table_state, context).rows def page(resource, table_state, context \\ %{}) def page(nil, table_state, context), do: page([], table_state, context) def page(%{repo: repo, schema: schema, index: nil} = resource, table_state, context) when not is_nil(repo) and not is_nil(schema) do page = Incant.Params.positive_integer(Map.get(table_state, :page), 1) page_size = Incant.Params.positive_integer(Map.get(table_state, :page_size), 25) total = query_count(resource, table_state, context) total_pages = max(ceil(total / page_size), 1) page = min(page, total_pages) table_state = table_state |> Map.put(:page, page) |> Map.put(:page_size, page_size) %{ rows: raw(resource, table_state, [paginate: true], context), page: page, page_size: page_size, total: total, total_pages: total_pages, meta: %{options: distinct_filter_options(resource, context)} } rescue error in [ ArgumentError, Ecto.QueryError, FunctionClauseError, Protocol.UndefinedError, RuntimeError, UndefinedFunctionError ] -> error_page(error, table_state) end def page(%{index: index} = resource, table_state, context) when not is_nil(index) do case load_index(resource, table_state, context) do %Incant.Result{} = result -> result_page(result, table_state) rows -> rows_page(process_rows(rows, resource, table_state), table_state) end rescue error in [ ArgumentError, FunctionClauseError, Protocol.UndefinedError, RuntimeError, UndefinedFunctionError ] -> error_page(error, table_state) end def page(resource, table_state, context) do resource |> all(table_state, context) |> rows_page(table_state) rescue error in [ ArgumentError, FunctionClauseError, Protocol.UndefinedError, RuntimeError, UndefinedFunctionError ] -> error_page(error, table_state) end defp all(resource, table_state, context) do resource |> raw(table_state, [], context) |> process_rows(resource, table_state) end defp process_rows(rows, resource, table_state) do rows |> search(resource.table.search, Map.get(table_state, :search)) |> filter(resource.table.filters, Map.get(table_state, :filters, %{})) |> sort(Map.get(table_state, :sort)) end defp rows_page(rows, table_state) do page = Incant.Params.positive_integer(Map.get(table_state, :page), 1) page_size = Incant.Params.positive_integer(Map.get(table_state, :page_size), 25) total = length(rows) total_pages = max(ceil(total / page_size), 1) page = min(page, total_pages) %{ rows: paginate(rows, %{page: page, page_size: page_size}), page: page, page_size: page_size, total: total, total_pages: total_pages, meta: %{} } end defp result_page(%Incant.Result{} = result, table_state) do page = Incant.Params.positive_integer(Map.get(result.meta, :page, table_state[:page]), 1) page_size = Incant.Params.positive_integer( Map.get(result.meta, :page_size, table_state[:page_size]), 25 ) total = result.total_count || length(result.rows) total_pages = max(ceil(total / page_size), 1) %{ rows: result.rows, page: min(page, total_pages), page_size: page_size, total: total, total_pages: total_pages, meta: Map.drop(result.meta, [:page, :page_size]) } end def one(resource, id, context \\ %{}) def one(_resource, nil, _context), do: nil def one(%{read: read} = resource, id, context) when not is_nil(read) do read |> Incant.Callback.call(id, context) |> normalize_one() rescue _error in [ ArgumentError, FunctionClauseError, Protocol.UndefinedError, UndefinedFunctionError ] -> one_from_rows(resource, id, context) end def one(%{repo: repo, schema: schema} = resource, id, context) when not is_nil(repo) and not is_nil(schema) do queryable = resource |> queryable(%{}, false, context) |> filter_by_id(resource, id) |> select_resource_fields(resource) repo |> apply(:one, [queryable]) |> normalize_one() rescue _error in [ ArgumentError, FunctionClauseError, Protocol.UndefinedError, UndefinedFunctionError ] -> one_from_rows(resource, id, context) end def one(resource, id, context), do: one_from_rows(resource, id, context) defp one_from_rows(resource, id, context) do resource |> raw(%{}, [], context) |> Enum.find(&(id(&1) == id)) rescue _error in [ArgumentError, FunctionClauseError, Protocol.UndefinedError] -> nil end defp normalize_one(nil), do: nil defp normalize_one(row), do: row defp error_page(error, table_state) do page = Incant.Params.positive_integer(Map.get(table_state, :page), 1) page_size = Incant.Params.positive_integer(Map.get(table_state, :page_size), 25) %{ rows: [], page: page, page_size: page_size, total: 0, total_pages: 1, error: Exception.message(error) } end def raw(resource, table_state \\ %{}, opts \\ [], context \\ %{}) def raw(nil, _table_state, _opts, _context), do: [] def raw(%{index: index} = resource, table_state, _opts, context) when not is_nil(index) do case load_index(resource, table_state, context) do %Incant.Result{rows: rows} -> rows rows -> rows end end def raw(%{repo: repo, schema: schema} = resource, table_state, opts, context) when not is_nil(repo) and not is_nil(schema) do queryable = resource |> queryable(table_state, Keyword.get(opts, :paginate, false), context) |> select_resource_fields(resource) repo |> apply(:all, [queryable]) |> Incant.Tabular.to_rows() end def raw(_resource, _table_state, _opts, _context), do: [] defp load_index(resource, table_state, context) do result = Incant.Callback.call(resource.index, %{table: table_state}, context) case result do %Incant.Result{} = result -> %{result | rows: result.rows |> Incant.Tabular.to_rows() |> scope_rows(resource, context)} rows -> rows |> Incant.Tabular.to_rows() |> scope_rows(resource, context) end end def id(%Incant.Service.Row{id: id}), do: id_string(id) def id(row), do: row |> field(:id) |> id_string() def title(row, resource) do link_column = Enum.find(resource.table.columns, & &1.opts[:link]) || List.first(resource.table.columns) case link_column do nil -> "Record #{id(row)}" column -> row |> field(column.name) |> to_string() end end def fields(%Incant.Service.Row{} = row) do Enum.map(row.cells, fn %Incant.Service.Cell{column: column, value: value} -> {column, format_detail_value(value)} end) end def fields(%_struct{} = row) do row |> Map.from_struct() |> Enum.map(fn {key, value} -> {key, format_detail_value(value)} end) end def fields(row) when is_map(row) do Enum.map(row, fn {key, value} -> {key, format_detail_value(value)} end) end def fields(_row), do: [] def field(%Incant.Service.Row{id: id}, field) when field in [:id, "id"], do: id def field(%Incant.Service.Row{} = row, field) do field = to_string(field) Enum.reduce_while(row.cells, nil, fn %Incant.Service.Cell{column: column, value: value}, _acc -> if column == field, do: {:halt, value}, else: {:cont, nil} end) end def field(row, field) do Map.get(row, field, Map.get(row, to_string(field))) end defp queryable(resource, table_state, paginate?, context) do queryable = resource |> base_queryable(table_state, context) |> scope_query(resource, context) query_context = %{ resource: resource, table: table_state, actor: Map.get(context, :actor) } filtered = resource.table.filters |> Incant.Filter.apply_filters( apply_query_search(queryable, resource, table_state, query_context), table_state_filters(table_state), query_context ) if paginate? do filtered |> sort_query(resource, table_state) |> paginate_query(table_state) else filtered end end defp base_queryable(%{query: nil, schema: schema}, _table_state, _context) do if function_exported?(schema, :__schema__, 1), do: from(row in schema), else: schema end defp base_queryable(resource, table_state, context) do Incant.Callback.call(resource.query, resource.schema, %{ table: table_state, actor: Map.get(context, :actor) }) end defp scope_query(queryable, resource, %{admin: admin, actor: actor} = context) do case Authorization.policy(admin, :view_resource, Map.put(context, :resource, resource)) do nil -> queryable policy when is_atom(policy) -> if function_exported?(policy, :scope_query, 4) do apply(policy, :scope_query, [actor, resource, queryable, context]) else queryable end end end defp scope_query(queryable, _resource, _context), do: queryable defp scope_rows(rows, resource, %{admin: admin, actor: actor} = context) do case Authorization.policy(admin, :view_resource, Map.put(context, :resource, resource)) do nil -> rows policy when is_atom(policy) -> if function_exported?(policy, :scope_rows, 4) do apply(policy, :scope_rows, [actor, resource, rows, context]) else rows end end end defp scope_rows(rows, _resource, _context), do: rows defp select_resource_fields(%Ecto.Query{select: nil} = queryable, resource) do fields = [primary_key(resource) | Enum.map(resource.table.columns, & &1.name)] |> Enum.concat(action_condition_fields(resource.table.actions)) |> Enum.filter(&(not is_nil(schema_field_type(resource.schema, &1)))) |> Enum.uniq() select(queryable, [row], map(row, ^fields)) end defp select_resource_fields(queryable, _resource), do: queryable defp action_condition_fields(actions) do Enum.flat_map(actions, fn action -> case metadata_opt(action.opts, :available_if, nil) do conditions when is_list(conditions) -> Keyword.keys(conditions) conditions when is_map(conditions) -> Map.keys(conditions) _predicate -> [] end end) end defp filter_by_id(%Ecto.Query{} = queryable, resource, id) do primary_key = primary_key(resource) id = cast_id(resource, primary_key, id) where(queryable, [row], field(row, ^primary_key) == ^id) end defp filter_by_id(queryable, _resource, _id), do: queryable defp primary_key(%{schema: schema}) when is_atom(schema) do if function_exported?(schema, :__schema__, 1) do schema |> apply(:__schema__, [:primary_key]) |> List.first() || :id else :id end end defp primary_key(_resource), do: :id defp cast_id(%{schema: schema}, field, id) when is_atom(schema) do with true <- function_exported?(schema, :__schema__, 2), type when not is_nil(type) <- apply(schema, :__schema__, [:type, field]), {:ok, casted} <- Ecto.Type.cast(type, id) do casted else _fallback -> id end end defp cast_id(_resource, _field, id), do: id defp query_count(%{repo: repo} = resource, table_state, context) do queryable = queryable(resource, table_state, false, context) direct_count(repo, queryable) || aggregate_count(repo, queryable) || subquery_count(repo, queryable, resource) || length(raw(resource, table_state, [], context)) end defp direct_count(repo, %Ecto.Query{} = queryable) do count_query = queryable |> exclude(:order_by) |> exclude(:select) |> select([_row], count()) integer_repo_result(fn -> apply(repo, :one, [count_query]) end) end defp direct_count(_repo, _queryable), do: nil defp aggregate_count(repo, queryable) do if function_exported?(repo, :aggregate, 2) do integer_repo_result(fn -> apply(repo, :aggregate, [queryable, :count]) end) end end defp subquery_count(repo, %Ecto.Query{} = queryable, resource) do if function_exported?(repo, :one, 1) do count_query = queryable |> exclude(:order_by) |> exclude(:select) |> select_resource_fields(resource) |> subquery() |> select([_row], count()) integer_repo_result(fn -> apply(repo, :one, [count_query]) end) end end defp subquery_count(_repo, _queryable, _resource), do: nil defp integer_repo_result(call) do case call.() do count when is_integer(count) -> count _other -> nil end rescue _error in [FunctionClauseError, UndefinedFunctionError] -> nil end defp paginate_query(queryable, %{page: page, page_size: page_size}) do page = Incant.Params.positive_integer(page, 1) page_size = Incant.Params.positive_integer(page_size, 25) queryable |> limit(^page_size) |> offset(^((page - 1) * page_size)) rescue _error in [ArgumentError, Ecto.QueryError, FunctionClauseError, Protocol.UndefinedError] -> queryable end defp paginate_query(queryable, _table_state), do: queryable defp table_state_filters(%{filters: filters}), do: filters defp table_state_filters(_table_state), do: %{} defp apply_query_search(queryable, _resource, %{search: search}, _context) when search in [nil, ""], do: queryable defp apply_query_search( queryable, %{table: %{search: fields}} = resource, table_state, _context ) when is_list(fields) or is_atom(fields) do fields = Enum.filter(List.wrap(fields), &searchable_field?(resource, &1)) case {fields, Map.get(table_state, :search)} do {[], _term} -> queryable {_fields, term} when term in [nil, ""] -> queryable {fields, term} -> search_fields(queryable, fields, term) end end defp apply_query_search(queryable, %{table: %{search: callback}}, table_state, context) when is_function(callback) or is_tuple(callback) do Incant.Callback.call(callback, queryable, %{ search: Map.get(table_state, :search), table: table_state, context: context }) end defp apply_query_search(queryable, _resource, _table_state, _context), do: queryable defp search_fields(queryable, fields, term) do pattern = "%#{term}%" expression = Enum.reduce(fields, dynamic(false), fn field_name, expression -> dynamic( [row], ^expression or fragment("lower(?) LIKE lower(?)", field(row, ^field_name), ^pattern) ) end) where(queryable, ^expression) end defp searchable_field?(%{schema: schema}, field_name) do schema_field_type(schema, field_name) in [:string, :binary_id] end defp sort_query(%Ecto.Query{} = queryable, resource, table_state) do allowed_fields = resource.table.columns |> Enum.reject(&(metadata_opt(&1.opts, :sortable, true) == false)) |> Enum.map(& &1.name) |> Enum.filter(&(not is_nil(schema_field_type(resource.schema, &1)))) Incant.Ecto.sort(queryable, table_state, allowed_fields, default: default_sort(resource.table.opts), tie_breaker: primary_key(resource) ) end defp sort_query(queryable, _resource, _table_state), do: queryable defp default_sort(opts) do case metadata_opt(opts, :default_sort, nil) do [{field_name, direction} | _rest] -> {field_name, direction} {field_name, direction} -> {field_name, direction} _other -> nil end end defp distinct_filter_options(resource, context) do case resource.repo do nil -> %{} repo -> queryable = resource |> base_queryable(%{}, context) |> scope_query(resource, context) resource.table.filters |> Enum.filter(&(metadata_opt(&1.opts, :options, nil) == :distinct)) |> Map.new(fn filter -> {to_string(filter.name), distinct_options(repo, queryable, resource, filter, context)} end) end rescue _error in [ArgumentError, Ecto.QueryError, RuntimeError, UndefinedFunctionError] -> %{} end defp distinct_options(repo, queryable, resource, filter, context) do if schema_field_type(resource.schema, filter.name) do field_name = filter.name limit = metadata_opt(filter.opts, :option_limit, 100) queryable |> exclude(:select) |> exclude(:order_by) |> where([row], not is_nil(field(row, ^field_name))) |> distinct(true) |> order_by([row], asc: field(row, ^field_name)) |> select([row], field(row, ^field_name)) |> limit(^limit) |> then(&apply(repo, :all, [&1])) |> Enum.map(&distinct_option(&1, filter, context)) else [] end end defp distinct_option(value, filter, context) do label = case metadata_opt(filter.opts, :option_label, nil) do nil -> to_string(value) callback -> Incant.Callback.call(callback, value, context) end %{label: to_string(label), value: value} end defp schema_field_type(schema, field_name) when is_atom(schema) and is_atom(field_name) do if function_exported?(schema, :__schema__, 2), do: apply(schema, :__schema__, [:type, field_name]) end defp schema_field_type(_schema, _field_name), do: nil defp metadata_opt(opts, key, default) when is_list(opts) or is_map(opts) do opts = Map.new(opts) Map.get(opts, key, Map.get(opts, to_string(key), default)) end defp metadata_opt(_opts, _key, default), do: default defp id_string(nil), do: nil defp id_string(""), do: nil defp id_string(value), do: to_string(value) defp search(rows, nil, _search), do: rows defp search(rows, _searchable, nil), do: rows defp search(rows, _searchable, ""), do: rows defp search(rows, searchable, search) do fields = List.wrap(searchable) needle = String.downcase(search) Enum.filter(rows, fn row -> Enum.any?(fields, fn field -> row |> Map.get(field) |> to_string() |> String.downcase() |> String.contains?(needle) end) end) end defp filter(rows, _definitions, filters) when map_size(filters) == 0, do: rows defp filter(rows, definitions, filters) do filters_by_name = Map.new(definitions, &{to_string(&1.name), &1}) Enum.filter(rows, fn row -> Enum.all?(filters, fn {field, value} -> case Map.fetch(filters_by_name, field) do {:ok, filter} -> Incant.Filter.match?(filter, row, value) :error -> true end end) end) end defp sort(rows, nil), do: rows defp sort(rows, ""), do: rows defp sort(rows, sort) do {direction, field} = sort_parts(sort) Enum.sort_by( rows, &Map.get(&1, String.to_existing_atom(field)), sort_direction_fun(direction) ) rescue ArgumentError -> rows end defp paginate(rows, table_state) do page = Incant.Params.positive_integer(Map.get(table_state, :page), 1) page_size = Incant.Params.positive_integer(Map.get(table_state, :page_size), 25) Enum.slice(rows, (page - 1) * page_size, page_size) end defp sort_parts("-" <> field), do: {:desc, field} defp sort_parts(field), do: {:asc, field} defp sort_direction_fun(:desc), do: :desc defp sort_direction_fun(:asc), do: :asc defp format_detail_value(value) when is_binary(value), do: value defp format_detail_value(value), do: inspect(value) end