lib/formula/evaluator.ex

defmodule Formula.Evaluator do
  @moduledoc """
  Module wrapper to evaluate the data or formula.
  """

  alias Explorer.Series
  alias Formula.Evaluable

  @spec evaluate(map()) :: map() | {:error, term()}
  def evaluate(data) do
    case do_evaluate(data) do
      result when is_map(result) ->
        result
        |> Enum.map(fn {k, v} -> {k, convert_value(v)} end)
        |> Enum.into(%{})

      {:error, error} ->
        {:error, error}
    end
  end

  defp do_evaluate(data) do
    Enum.reduce_while(data, %{}, fn {key, formula}, acc ->
      case Evaluable.evaluate(formula, data) do
        {:ok, value} -> {:cont, Map.put(acc, key, value)}
        {:error, error} -> {:halt, {:error, error}}
      end
    end)
  end

  defp convert_value(%Series{} = series) do
    series
    |> Series.to_list()
    |> Enum.map(&Decimal.from_float/1)
  end

  defp convert_value(value), do: value
end

defprotocol Formula.Evaluable do
  @fallback_to_any true

  @spec evaluate(term(), map()) :: {:ok, term()} | {:error, term()} | {:error, list(term())}
  def evaluate(value, data)
end

defimpl Formula.Evaluable, for: Any do
  @spec evaluate(term(), map()) :: {:ok, term()}
  def evaluate(value, _data) do
    {:ok, value}
  end
end

defimpl Formula.Evaluable, for: List do
  alias Formula.Helpers

  @spec evaluate(list(term()), map()) :: {:ok, list(term())} | {:error, list(term())}
  def evaluate(value, data) do
    case Helpers.convert_list(value, &@protocol.evaluate(&1, data)) do
      {:ok, list} -> {:ok, list}
      {:error, errors} -> {:error, errors}
    end
  end
end

defimpl Formula.Evaluable, for: Formula.Symbol do
  alias Formula.Errors.MissingArgumentsError
  alias Formula.Symbol

  @spec evaluate(Symbol.t(), map()) :: {:ok, term()} | {:error, term()}
  def evaluate(%{name: name}, data) do
    case Map.fetch(data, name) do
      {:ok, value} ->
        {:ok, value}

      :error ->
        {:error, MissingArgumentsError.exception(message: "Missing arguments", argument: name)}
    end
  end
end

defimpl Formula.Evaluable, for: Formula.Function do
  alias Formula.Function

  @spec evaluate(Function.t(), map()) :: {:ok, term()} | {:error, term()}
  def evaluate(%{name: name, arguments: arguments}, data) do
    case @protocol.evaluate(arguments, data) do
      {:ok, arguments} -> @for.Runtime.exec(name, arguments)
      {:error, error} -> {:error, error}
    end
  end
end