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 a function
  > to query it, __live_admin_config__/0.
  """

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

  alias Ecto.Changeset
  alias PhoenixHTMLHelpers.Tag

  @doc """
  Configure a module to act as a LiveAdmin resource

  In addition to global options, also accepts `schema` in case the resource
  is not also an Ecto schema.
  """
  defmacro __using__(opts) do
    opts_schema =
      LiveAdmin.base_configs_schema() ++
        [
          schema: [type: :atom, default: __CALLER__.module],
          preload: [type: {:or, [:keyword_list, nil]}, default: nil]
        ]

    quote bind_quoted: [opts: opts, opts_schema: opts_schema] do
      opts = NimbleOptions.validate!(opts, opts_schema)

      @__live_admin_config__ opts

      def __live_admin_config__, do: @__live_admin_config__
    end
  end

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

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

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

  def all(keys, resource, prefix, repo) do
    key = LiveAdmin.primary_key!(resource)

    resource.__live_admin_config__()
    |> Keyword.fetch!(:schema)
    |> where([s], field(s, ^key) in ^keys)
    |> repo.all(prefix: prefix)
  end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  def update(record, resource, params, session, config) do
    resource
    |> LiveAdmin.fetch_config(:update_with, config)
    |> case do
      nil ->
        repo = LiveAdmin.fetch_config(resource, :ecto_repo, config)

        resource
        |> change(record, params, config)
        |> repo.update()

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

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

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

  def fields(resource, config) do
    schema = Keyword.fetch!(resource.__live_admin_config__(), :schema)

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

        true ->
          []
      end
    end)
  end

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

    query =
      resource.__live_admin_config__()
      |> Keyword.fetch!(:schema)
      |> 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, config))

        _, 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, config) do
    resource
    |> case do
      :embed ->
        Enum.map(schema.__schema__(:fields), fn field_name ->
          {field_name, schema.__schema__(:type, field_name), []}
        end)

      resource ->
        fields(resource, config)
    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, config) 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, config) 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
    resource.__live_admin_config__()
    |> Keyword.fetch!(:preload)
    |> case do
      nil ->
        resource.__live_admin_config__()
        |> Keyword.fetch!(:schema)
        |> parent_associations()
        |> Enum.map(& &1.field)

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

      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, &Tag.content_tag(:pre, inspect(&1, pretty: true)))

  defp render_field(val) when is_binary(val), do: val
  defp render_field(val), do: inspect(val)
end