lib/schemas/traits/trait_value.ex

defmodule Flagsmith.Schemas.Traits.Trait.Value do
  @behaviour Ecto.Type

  @moduledoc """
  Ecto type to aid casting values returned by the Flagsmith API that need to be
  compared. Since those values are weakly typed in different places and they need
  to be compared, it's necessary to be able to convert and assign a type to a value
  in order to when comparing that value with another one, the other one can be cast
  to the same type.

  This ecto type works as a struct containing the converted value in its correctly 
  typed format and a field `:type` containing the corresponding type, so that it's
  easier to then cast any other stringed type into the same format.
  """

  @impl Ecto.Type
  def type(), do: :map

  @type t() :: %__MODULE__{
          :value => String.t() | Decimal.t() | Version.t() | number() | boolean(),
          :type => :string | :decimal | :boolean | :semver
        }

  @derive {Jason.Encoder, only: [:value, :type]}

  @enforce_keys [:value, :type]
  defstruct [:value, :type]

  @impl Ecto.Type
  def load(data), do: cast(data)

  @impl Ecto.Type
  def cast(%__MODULE__{value: _, type: _} = data), do: {:ok, data}

  def cast(%{"value" => value, "type" => "decimal"}),
    do: {:ok, %__MODULE__{value: Decimal.new(value), type: :decimal}}

  def cast(%{"value" => value, "type" => type}),
    do: {:ok, %__MODULE__{value: value, type: String.to_existing_atom(type)}}

  def cast(data) when is_number(data),
    do: {:ok, %__MODULE__{value: convert_number(data), type: :decimal}}

  def cast(%Decimal{} = data),
    do: {:ok, %__MODULE__{value: data, type: :decimal}}

  def cast(data) when data in ["false", "true"],
    do: {:ok, %__MODULE__{value: String.to_existing_atom(data), type: :boolean}}

  def cast(data) when data in ["False", "True"],
    do:
      {:ok,
       %__MODULE__{value: String.downcase(data) |> String.to_existing_atom(), type: :boolean}}

  def cast(data) when data in [false, true],
    do: {:ok, %__MODULE__{value: data, type: :boolean}}

  def cast(data) when is_binary(data) do
    case String.ends_with?(data, ":semver") do
      true ->
        create_semver(data)

      false ->
        {:ok, %__MODULE__{value: data, type: :string}}
    end
  end

  def cast(_), do: :error

  @impl Ecto.Type
  def dump(%__MODULE__{value: _, type: _} = data), do: {:ok, data}
  def dump(%{"value" => _, "type" => _type} = data), do: {:ok, data}

  def dump(data) when is_number(data),
    do: {:ok, %__MODULE__{value: data, type: :decimal}}

  def dump(%Decimal{} = data),
    do: {:ok, %__MODULE__{value: Decimal.to_string(data), type: :decimal}}

  def dump(data) when data in ["false", "true"],
    do: {:ok, %__MODULE__{value: String.to_existing_atom(data), type: :boolean}}

  def dump(data) when data in ["False", "True"],
    do:
      {:ok,
       %__MODULE__{value: String.downcase(data) |> String.to_existing_atom(), type: :boolean}}

  def dump(data) when data in [false, true],
    do: {:ok, %__MODULE__{value: data, type: :boolean}}

  def dump(data) when is_binary(data),
    do: {:ok, %__MODULE__{value: data, type: :string}}

  def dump(_), do: :error

  @impl Ecto.Type
  def embed_as(_), do: :dump

  @impl Ecto.Type
  def equal?(term_1, term_1), do: true
  def equal?(term_1, term_2), do: get_term(term_1) == get_term(term_2)

  def convert_value_to(%__MODULE__{type: :boolean}, to_convert)
      when to_convert in ["false", "true", "False", "True"],
      do: cast(to_convert)

  def convert_value_to(%__MODULE__{type: :semver}, to_convert),
    do: Version.parse(to_convert)

  def convert_value_to(%__MODULE__{type: type}, to_convert),
    do: Ecto.Type.cast(type, to_convert)

  defp get_term(data) do
    case cast(data) do
      {:ok, value} -> value
      :error -> {:error, data}
    end
  end

  defp convert_number(data) when is_float(data),
    do: Decimal.from_float(data)

  defp convert_number(data) when is_integer(data) or is_binary(data),
    do: Decimal.new(data)

  def is_semver(data) when is_binary(data),
    do: String.ends_with?(data, ":semver")

  def is_semver(_), do: false

  def create_semver(%Version{} = version), do: {:ok, %__MODULE__{value: version, type: :semver}}

  def create_semver(data) when is_binary(data) do
    without_semver = String.replace_suffix(data, ":semver", "")
    semver = Version.parse!(without_semver)

    {:ok, %__MODULE__{value: semver, type: :semver}}
  end
end