lib/map_sorter.ex

defmodule MapSorter do
  @moduledoc """
  Sorts a list of maps per a list of sort specs.

  Also supports:

  - keywords
  - structs implementing the `Access` behaviour
  - nested maps, keywords or structs implementing the `Access` behaviour
  """

  use PersistConfig

  alias __MODULE__.{Log, SortSpecs}

  @default_formatter Logger.default_formatter()
  @logger_env get_app_env(:file_only_logger, :logger, [])

  @doc """
  Sorts `maps` per the given `sort_specs`.

  Examples of sort specs for flat data structures:
  ```
  - implicit: [:dob, :name]
  - mixed:    [:dob, desc: :name]
  - explicit: [asc: :dob, desc: :name]
  ```

  Examples of sort specs with a `Date` key for flat data structures:
  ```
  - implicit: [{:dob, Date}, :name]
  - mixed:    [{:dob, Date}, desc: :name]
  - explicit: [asc: {:dob, Date}, desc: :name]
  ```

  Examples of sort specs for nested data structures:
  ```
  - implicit: [[:birth, :date], :name]
  - mixed   : [[:birth, :date], desc: :name]
  - explicit: [asc: [:birth, :date], desc: :name]
  ```

  Examples of sort specs with a `Date` key for nested data structures:
  ```
  - implicit: [{[:birth, :date], Date}, :name]
  - mixed:    [{[:birth, :date], Date}, desc: :name]
  - explicit: [asc: {[:birth, :date], Date}, desc: :name]
  ```

  ## Examples

      iex> require MapSorter
      iex> people = [
      ...>   %{name: "Mike", likes: "movies" , dob: "1992-04-15"},
      ...>   %{name: "Mary", likes: "travels", dob: "1992-04-15"},
      ...>   %{name: "Bill", likes: "karate" , dob: "1977-08-28"},
      ...>   %{name: "Joe" , likes: "boxing" , dob: "1977-08-28"},
      ...>   %{name: "Jill", likes: "cooking", dob: "1976-09-28"}
      ...> ]
      iex> sort_specs = Tuple.to_list({:dob, {:desc, :likes}})
      iex> sorted_people = %{
      ...>   explicit: MapSorter.sort(people, asc: :dob, desc: :likes),
      ...>   mixed:    MapSorter.sort(people, [:dob, desc: :likes]),
      ...>   runtime:  MapSorter.sort(people, sort_specs)
      ...> }
      iex> sorted_people.explicit == sorted_people.mixed and
      ...> sorted_people.explicit == sorted_people.runtime and
      ...> sorted_people.explicit
      [
        %{name: "Jill", likes: "cooking", dob: "1976-09-28"},
        %{name: "Bill", likes: "karate" , dob: "1977-08-28"},
        %{name: "Joe" , likes: "boxing" , dob: "1977-08-28"},
        %{name: "Mary", likes: "travels", dob: "1992-04-15"},
        %{name: "Mike", likes: "movies" , dob: "1992-04-15"}
      ]
  """
  defmacro sort(maps, sort_specs) do
    # To enforce logger configuration at compile time.
    # Otherwise logger will use default configuration.
    # dbg_handler_config("Before setting/adding logger handlers...", __CALLER__)
    :ok = :logger.set_handler_config(:default, :formatter, @default_formatter)

    for {:handler, id, :logger_std_h, config} <- @logger_env do
      case :logger.add_handler(id, :logger_std_h, config) do
        :ok -> :ok
        {:error, {:already_exist, ^id}} -> :ok
      end
    end

    # dbg_handler_config("After setting/adding logger handlers...", __CALLER__)

    specs =
      case sort_specs do
        specs when is_list(specs) ->
          # [asc: {:dob, {:__aliases__, [line: 7], [:Date]}}] =>
          # [asc: {:dob, Date}]
          {specs, []} = Code.eval_quoted(specs)
          specs

        specs ->
          # In case any module attributes...
          Macro.expand(specs, __CALLER__)
      end

    :ok = Log.debug(:sort_specs, {sort_specs, specs, __ENV__, __CALLER__})

    case SortSpecs.to_quoted(specs) do
      {:ok, fun_ast} ->
        quote do: Enum.sort(unquote(maps), unquote(fun_ast))

      {:error, invalid_specs} ->
        :ok = Log.warning(:invalid_specs, {invalid_specs, __ENV__, __CALLER__})
        maps
    end
  end

  ## Private functions

  # defp get_handler_config(id, color) do
  #   case :logger.get_handler_config(id) do
  #     {:ok, %{config: %{type: :file, file: file}, level: _level}} ->
  #       file

  #     {:ok,
  #      %{
  #        config: %{type: :standard_io},
  #        level: level,
  #        formatter: {Logger.Formatter, %Logger.Formatter{colors: colors}}
  #      }} ->
  #       {id, level, colors[color]}

  #     {:error, {:not_found, ^id} = error} ->
  #       error
  #   end
  # end

  # @sep "==========================================================="
  # @yellow "\e[33m"
  # @light_yellow "\e[93m"
  # @reset "\e[0m"

  # defp dbg_handler_config(msg, env) do
  #   """
  #   #{@yellow}#{@sep}
  #   #{@light_yellow}#{msg}
  #   #{inspect(env.module)}.#{inspect(env.function)}:#{env.line}
  #   #{get_handler_config(:debug_handler, :debug) |> inspect()}
  #   #{get_handler_config(:default, :debug) |> inspect()}
  #   #{@yellow}#{@sep}#{@reset}
  #   """
  #   |> IO.puts()
  # end
end