lib/live_admin/resource.ex

defmodule LiveAdmin.Resource do
  @moduledoc """
  API for managing Ecto schemas and their individual record instances used internally by LiveAdmin.

  > #### `use LiveAdmin.Resource` {: .info}
  > This is required in any module that should act as a LiveAdmin Resource.
  > If the module is not an Ecto schema, then the `:schema` option must be passed.
  > Using this module will create a __live_admin_config__ module variable and 2 functions
  > to query it, __live_admin_config__/0 and __live_admin_config__/1. The former returns the entire
  > config while the latter will return a key if it exists, otherwise it will fallback
  > to either a global config for that key, or the key's default value.

  To customize UI behavior, the following options may also be used:

  * `title_with` - a binary, or MFA that returns a binary, used to identify the resource
  * `label_with` - a binary, or MFA that returns a binary, used to identify records
  * `list_with` - an atom or MFA that identifies the function that implements listing the resource
  * `create_with` - an atom or MFA that identifies the function that implements creating the resource (set to false to disable create)
  * `update_with` - an atom or MFA that identifies the function that implements updating a record (set to false to disable update)
  * `delete_with` - an atom or MFA that identifies the function that implements deleting a record (set to false to disable delete)
  * `validate_with` - an atom or MFA that identifies the function that implements validating a changed record
  * `render_with` - an atom or MFA that identifies the function that implements table field rendering logic
  * `hidden_fields` - a list of fields that should not be displayed in the UI
  * `immutable_fields` - a list of fields that should not be editable in forms
  * `actions` - list of atoms or MFAs that identify a function that operates on a record
  * `tasks` - list atoms or MFAs that identify a function that operates on a resource
  * `components` - keyword list of component module overrides for specific views (`:list`, `:new`, `:edit`, `:home`, `:nav`, `:session`, `:view`)
  * `ecto_repo` - Ecto repo to use when building queries for this resource
  """

  import Ecto.Query
  import LiveAdmin, only: [record_label: 2, parent_associations: 1]

  alias Ecto.Changeset
  alias PhoenixHTMLHelpers.{Format, Tag}

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @__live_admin_config__ Keyword.put_new(opts, :schema, __MODULE__)

      def __live_admin_config__, do: @__live_admin_config__

      def __live_admin_config__(key) do
        @__live_admin_config__
        |> Keyword.get(key, Application.get_env(:live_admin, key))
        |> case do
          false -> false
          nil -> LiveAdmin.Resource.default_config_value(key)
          config -> config
        end
      end
    end
  end

  def render(record, field, resource, assoc_resource, session) do
    :render_with
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        if assoc_resource do
          record_label(
            Map.fetch!(record, get_assoc_name!(resource.__live_admin_config__(:schema), field)),
            elem(assoc_resource, 1)
          )
        else
          record
          |> Map.fetch!(field)
          |> render_field()
        end

      {m, f, a} ->
        apply(m, f, [record, field, session] ++ a)

      f when is_atom(f) ->
        apply(resource, f, [record, field, session])
    end
  end

  def all(ids, resource, prefix, repo) do
    resource.__live_admin_config__(:schema)
    |> where([s], s.id in ^ids)
    |> repo.all(prefix: prefix)
  end

  def find!(id, resource, prefix, repo) do
    find(id, resource, prefix, repo) ||
      raise(Ecto.NoResultsError, queryable: resource.__live_admin_config__(:schema))
  end

  def find(id, resource, prefix, repo) do
    resource.__live_admin_config__(:schema)
    |> preload(^preloads(resource))
    |> repo.get(id, prefix: prefix)
  end

  def delete(record, resource, session, repo) do
    :delete_with
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        repo.delete(record)

      {mod, func_name, args} ->
        apply(mod, func_name, [record, session] ++ args)

      name when is_atom(name) ->
        apply(resource, name, [record, session])
    end
  end

  def list(resource, opts, session, repo) do
    :list_with
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        build_list(resource, opts, repo)

      {mod, func_name, args} ->
        apply(mod, func_name, [resource, opts, session] ++ args)

      name when is_atom(name) ->
        apply(resource, name, [opts, session])
    end
  end

  def change(resource, record \\ nil, params \\ %{})

  def change(resource, record, params) when is_struct(record) do
    build_changeset(record, resource, params)
  end

  def change(resource, nil, params) do
    :schema
    |> resource.__live_admin_config__()
    |> struct(%{})
    |> build_changeset(resource, params)
  end

  def create(resource, params, session, repo) do
    :create_with
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        resource
        |> change(nil, params)
        |> repo.insert(prefix: session.prefix)

      {mod, func_name, args} ->
        apply(mod, func_name, [params, session] ++ args)

      name when is_atom(name) ->
        apply(resource, name, [params, session])
    end
  end

  def update(record, resource, params, session) do
    :update_with
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        resource
        |> change(record, params)
        |> resource.__live_admin_config__(:ecto_repo).update()

      {mod, func_name, args} ->
        apply(mod, func_name, [record, params, session] ++ args)

      name when is_atom(name) ->
        apply(resource, name, [record, params, session])
    end
  end

  def validate(changeset, resource, session) do
    :validate_with
    |> resource.__live_admin_config__()
    |> case do
      nil -> changeset
      {mod, func_name, args} -> apply(mod, func_name, [changeset, session] ++ args)
      name when is_atom(name) -> apply(resource, name, [changeset, session])
    end
    |> Map.put(:action, :validate)
  end

  def fields(resource) do
    schema = resource.__live_admin_config__(:schema)

    Enum.flat_map(schema.__schema__(:fields), fn field_name ->
      :hidden_fields
      |> resource.__live_admin_config__()
      |> Enum.member?(field_name)
      |> case do
        false ->
          [
            {field_name, schema.__schema__(:type, field_name),
             [
               immutable:
                 Enum.member?(resource.__live_admin_config__(:immutable_fields) || [], field_name)
             ]}
          ]

        true ->
          []
      end
    end)
  end

  def default_config_value(key) when key in [:actions, :tasks, :components, :hidden_fields],
    do: []

  def default_config_value(:label_with), do: :id

  def default_config_value(_), do: nil

  defp build_list(resource, opts, repo) do
    opts =
      opts
      |> Enum.into(%{})
      |> Map.put_new(:page, 1)
      |> Map.put_new(:sort_dir, :asc)
      |> Map.put_new(:sort_attr, :id)

    query =
      :schema
      |> resource.__live_admin_config__()
      |> limit(10)
      |> offset(^((opts[:page] - 1) * 10))
      |> order_by(^[{opts[:sort_dir], opts[:sort_attr]}])
      |> preload(^preloads(resource))

    query =
      opts
      |> Enum.reduce(query, fn
        {:search, q}, query when byte_size(q) > 0 ->
          apply_search(query, q, fields(resource))

        _, query ->
          query
      end)

    {
      repo.all(query, prefix: opts[:prefix]),
      repo.aggregate(
        query |> exclude(:limit) |> exclude(:offset),
        :count,
        prefix: opts[:prefix]
      )
    }
  end

  defp apply_search(query, q, fields) do
    q
    |> String.split(~r{[^\s]*:}, include_captures: true, trim: true)
    |> case do
      [q] ->
        matcher = if String.contains?(q, "%"), do: q, else: "%#{q}%"

        Enum.reduce(fields, query, fn {field_name, _, _}, query ->
          or_where(
            query,
            [r],
            like(
              fragment("LOWER(CAST(? AS text))", field(r, ^field_name)),
              ^String.downcase(matcher)
            )
          )
        end)

      field_queries ->
        field_queries
        |> Enum.map(&String.trim/1)
        |> Enum.chunk_every(2)
        |> Enum.reduce(query, fn
          [field_key, q], query ->
            fields
            |> Enum.find_value(fn {field_name, _, _} ->
              if "#{field_name}:" == field_key, do: field_name
            end)
            |> case do
              nil ->
                query

              field_name ->
                or_where(
                  query,
                  [r],
                  ilike(fragment("CAST(? AS text)", field(r, ^field_name)), ^"%#{q}%")
                )
            end

          _, query ->
            query
        end)
    end
  end

  defp build_changeset(record = %schema{}, resource, params) do
    resource
    |> case do
      :embed ->
        Enum.map(schema.__schema__(:fields), fn field_name ->
          {field_name, schema.__schema__(:type, field_name), []}
        end)

      resource ->
        fields(resource)
    end
    |> Enum.reduce(Changeset.cast(record, params, []), fn
      {field_name, {_, Ecto.Embedded, %{cardinality: :many}}, _}, changeset ->
        Changeset.cast_embed(changeset, field_name,
          with: fn embed, params -> build_changeset(embed, :embed, params) end,
          sort_param: LiveAdmin.View.sort_param_name(field_name),
          drop_param: LiveAdmin.View.drop_param_name(field_name)
        )

      {field_name, {_, Ecto.Embedded, %{cardinality: :one}}, _}, changeset ->
        if Map.get(params, to_string(field_name)) == "" do
          Changeset.put_change(changeset, field_name, nil)
        else
          Changeset.cast_embed(changeset, field_name,
            with: fn embed, params -> build_changeset(embed, :embed, params) end
          )
        end

      {field_name, type, opts}, changeset ->
        unless Keyword.get(opts, :immutable, false) do
          changeset = Changeset.cast(changeset, params, [field_name])

          if type == :map do
            Changeset.update_change(changeset, field_name, &parse_map_param/1)
          else
            changeset
          end
        else
          changeset
        end
    end)
  end

  defp parse_map_param(param = %{}) do
    param
    |> Enum.sort_by(fn {idx, _} -> idx end)
    |> Map.new(fn {_, %{"key" => key, "value" => value}} -> {key, value} end)
  end

  defp parse_map_param(param), do: param

  defp preloads(resource) do
    :preload
    |> resource.__live_admin_config__()
    |> case do
      nil ->
        resource.__live_admin_config__(:schema)
        |> parent_associations()
        |> Enum.map(& &1.field)

      {m, f, a} ->
        apply(m, f, [resource | a])

      preloads when is_list(preloads) ->
        preloads
    end
  end

  defp get_assoc_name!(schema, fk) do
    Enum.find(schema.__schema__(:associations), fn assoc_name ->
      fk == schema.__schema__(:association, assoc_name).owner_key
    end)
  end

  defp render_field(val = %{}), do: Tag.content_tag(:pre, inspect(val, pretty: true))
  defp render_field(val) when is_list(val), do: Enum.map(val, &render_field/1)
  defp render_field(val) when is_binary(val), do: Format.text_to_html(val)
  defp render_field(val), do: inspect(val)
end