lib/archeometer/repo/result.ex

defmodule Archeometer.Repo.Result do
  @moduledoc """
  A query result with a nicer API to manipulate the data.
  """
  defstruct [:headers, :rows]

  defimpl String.Chars do
    @vsep " |"
    @hsep "-"
    @vprefix " |"

    def to_string(%Archeometer.Repo.Result{headers: headers, rows: rows}) do
      widths = widths([headers | rows])
      hsep = Enum.map(widths, fn w -> String.duplicate(@hsep, w) end)

      [headers, hsep | rows]
      |> Enum.map_join(&(@vprefix <> format_row(&1, widths) <> "\n"))
    end

    defp format_row(row, widths) do
      Enum.zip(row, widths)
      |> Enum.map_join(fn {r, w} ->
        r
        |> Kernel.to_string()
        |> String.pad_trailing(w)
        |> Kernel.<>(@vsep)
      end)
    end

    defp widths(rows) do
      rows
      |> Enum.zip()
      |> Enum.map(fn tpl ->
        tpl
        |> Tuple.to_list()
        |> Enum.map(&(Kernel.to_string(&1) |> String.length()))
        |> Enum.max()
      end)
    end
  end

  @doc """
  Merges two query results into one using a common column to pair the results values.
  Rows from second result are dumped into first one.

  ## Examples

      iex> result1 = 
      ...>   %Archeometer.Repo.Result{
      ...>     headers: [:id, :name], rows: [[1, "archeometer"]]}
      ...>
      iex> result2 = 
      ...>   %Archeometer.Repo.Result{
      ...>     headers: [:name, :num_mods], rows: [["archeometer", 120]]}
      ...>
      iex> Archeometer.Repo.Result.merge(result1, result2, :name)
      %Archeometer.Repo.Result{
        headers: [:id, :name, :num_mods],
        rows: [[1, "archeometer", 120]]
      }

  Trying to merge without a common column

      iex> result1 = 
      ...>   %Archeometer.Repo.Result{
      ...>     headers: [:id, :is_external], rows: [[1, false]]}
      ...>
      iex> result2 = 
      ...>   %Archeometer.Repo.Result{
      ...>     headers: [:name, :num_mods], rows: [["archeometer", 120]]}
      ...>
      iex> Archeometer.Repo.Result.merge(result1, result2, :name)
      {:error, "name is not a common column"}
  """
  def merge(result1, result2, joiner, default \\ nil) do
    joiner1 = Enum.find_index(result1.headers, &(&1 == joiner))
    joiner2 = Enum.find_index(result2.headers, &(&1 == joiner))

    if is_nil(joiner1) or is_nil(joiner2) do
      {:error, "#{joiner} is not a common column"}
    else
      default = for _ <- 1..length(result2.headers), do: default

      Map.update!(result1, :rows, fn rows ->
        Enum.map(rows, &update_row(&1, result2.rows, joiner1, joiner2, default))
      end)
      |> Map.update!(:headers, &(&1 ++ List.delete_at(result2.headers, joiner2)))
    end
  end

  defp update_row(current_row, result_rows2, joiner1, joiner2, default) do
    other_values =
      Enum.find(result_rows2, default, &(Enum.at(current_row, joiner1) == Enum.at(&1, joiner2)))
      |> List.delete_at(joiner2)

    current_row ++ other_values
  end
end