lib/delta.ex

defmodule Delta do
  @moduledoc "Simple difference engine"

  @type delta() :: {:update, any(), any()} | {:delete, any()} | {:add, any()}
  @type path() :: list()
  @type delta_spec() :: [{path(), delta()}]

  @doc """
  Takes delta between two items

  ## Examples

      iex> Delta.delta(%{ a: 1, b: 2, c: 3}, %{ a: 2, b: 2, d: 4 })
      [
        {[:a], {:update, 1, 2}},
        {[:c], {:delete, 3}},
        {[:d], {:add, 4}}
      ]

      iex> Delta.delta([:a, :b, :c], [:a, :d, :c])
      [
        {[1], {:update, :b, :d}}
      ]

      iex> Delta.delta({:a, :b, :c}, {:a, :d, :c})
      [
        {[1], {:update, :b, :d}}
      ]

      iex> Delta.delta(:apple, :orange)
      [
        {[], {:update, :apple, :orange}}
      ]

      iex> Delta.delta(:apple, :apple)
      []

      iex> Delta.delta("", :orange)
      [
        {[], {:add, :orange}}
      ]

      iex> Delta.delta(:apple, "")
      [
        {[], {:delete, :apple}}
      ]

      iex> Delta.delta({:a, :b, :c}, {:a, :d, :c})
      [
        {[1], {:update, :b, :d}}
      ]

      iex> Delta.delta({:a, :b, :c}, {:a, :b, :c})
      []

      iex> Delta.delta({:a, :b, :c}, {:a, :b, :c, :d})
      [
        {[], {:update, {:a, :b, :c}, {:a, :b, :c, :d}}}
      ]

      iex> Delta.delta([:a, :b, :c], [:a, :d, :c])
      [
        {[1], {:update, :b, :d}}
      ]

      iex> Delta.delta([:a, :b, :c], [:a, :b, :c])
      []

      iex> Delta.delta([:a, :b, :c], [:a, :b, :c, :d])
      [
        {[], {:update, [:a, :b, :c], [:a, :b, :c, :d]}}
      ]

      iex> Delta.delta(%{ a: 1, b: 2, c: 3}, %{ a: 2, b: 2, d: 4 })
      [
        {[:a], {:update, 1, 2}},
        {[:c], {:delete, 3}},
        {[:d], {:add, 4}}
      ]
  """
  @spec delta(any(), any()) :: delta_spec()
  def delta(a, b) do
    []
    |> delta(a, b)
    |> List.flatten()
    |> Enum.map(fn {path, element} -> {Enum.reverse(path), element} end)
    |> Enum.sort_by(fn {path, _} -> {length(path), path} end)
  end

  @spec delta(path(), any(), any()) :: delta_spec()
  defp delta(path, a, b) when is_struct(a) and is_struct(b), do: delta_struct(path, a, b)
  defp delta(path, a, b) when is_map(a) and is_map(b), do: delta_map(path, a, b)
  defp delta(path, a, b) when is_list(a) and is_list(b), do: delta_list(path, a, b)
  defp delta(path, a, b) when is_tuple(a) and is_tuple(b), do: delta_tuple(path, a, b)
  defp delta(path, a, b), do: delta_simple(path, a, b)

  @spec delta_struct(path(), struct(), struct()) :: delta_spec()
  def delta_struct(path, %a_s{} = a, %b_s{} = b) when a_s != b_s, do: delta_simple(path, a, b)
  def delta_struct(path, a, b), do: delta_map(path, Map.from_struct(a), Map.from_struct(b))

  @spec delta_map(path(), map(), map()) :: delta_spec()
  def delta_map(path, a, b) do
    a_keys = MapSet.new(Map.keys(a))
    b_keys = MapSet.new(Map.keys(b))
    common = MapSet.intersection(a_keys, b_keys)
    a_only = a_keys |> MapSet.difference(common) |> Enum.to_list()
    b_only = b_keys |> MapSet.difference(common) |> Enum.to_list()

    Enum.flat_map(Enum.to_list(common), fn key -> delta([key | path], a[key], b[key]) end) ++
      Enum.flat_map(a_only, fn key -> [{[key | path], {:delete, a[key]}}] end) ++
      Enum.flat_map(b_only, fn key -> [{[key | path], {:add, b[key]}}] end)
  end

  @spec delta_list(path(), list(), list()) :: delta_spec()
  defp delta_list(path, as, bs) when length(as) != length(bs), do: delta_simple(path, as, bs)
  defp delta_list(path, as, bs) do
    0..(length(as) - 1)
    |> Enum.to_list()
    |> Enum.zip(Enum.zip(as, bs))
    |> Enum.flat_map(fn {i, {a, b}} -> delta([i | path], a, b) end)
  end

  @spec delta_tuple(path(), tuple(), tuple()) :: delta_spec()
  defp delta_tuple(path, a, b) when tuple_size(a) != tuple_size(b), do: delta_simple(path, a, b)
  defp delta_tuple(path, a, b), do: delta_list(path, Tuple.to_list(a), Tuple.to_list(b))

  @spec delta_simple(path(), term(), term()) :: delta_spec()
  defp delta_simple(_path, a, b) when a == b, do: []
  defp delta_simple(path, a, b) do
    cond do
      boring?(a) -> [{path, {:add, b}}]
      boring?(b) -> [{path, {:delete, a}}]
      true -> [{path, {:update, a, b}}]
    end
  end

  @boring [false, "", nil, [], 0, 0.0]

  @doc """
  Identifies boring values

  ## Examples

    iex> Delta.boring?(false)
    true

    iex> Delta.boring?("")
    true

    iex> Delta.boring?(nil)
    true

    iex> Delta.boring?([])
    true

    iex> Delta.boring?(0)
    true

    iex> Delta.boring?(0.0)
    true

    iex> Delta.boring?([false, "", nil, [], 0, 0.0])
    true

    iex> Delta.boring?(%{a: false, b: "", c: nil, d: [], e: 0, f: 0.0})
    true

    iex> Delta.boring?(%{foo: ""})
    true

    iex> Delta.boring?(%{foo: "185 Berry St"})
    false

    iex> Delta.boring?("boring")
    false
  """
  @spec boring?(any()) :: boolean()
  def boring?(value) when value in @boring, do: true
  def boring?(list = [_ | _]), do: Enum.all?(list, &boring?/1)
  def boring?(struct = %_{}), do: struct |> Map.from_struct() |> boring?()
  def boring?(map = %{}), do: map |> Map.values() |> boring?()
  def boring?(_), do: false
end