lib/growth_book/condition.ex

defmodule GrowthBook.Condition do
  @moduledoc """
  Functionality for evaluating conditions.

  You should not (have to) use any of these functions in your own application. They are documented
  for library developers only. Breaking changes in this module will not be considered breaking
  changes in the library's public API (or cause a minor/major semver update).
  """

  alias GrowthBook.Context
  alias GrowthBook.Helpers

  @typedoc """
  Condition

  A condition is evaluated against `t:GrowthBook.Context.attributes/0` and used to target features/experiments
  to specific users.

  The syntax is inspired by MongoDB queries. Here is an example:

  ```
  %{
    "country" => "US",
    "browser" => %{
      "$in" => ["firefox", "chrome"]
    },
    "email" => %{
      "$not" => %{
        "$regex" => "@gmail.com$"
      }
    }
  }
  ```
  """
  @type t() :: map()

  @typedoc "A condition value"
  @type condition_value() :: term()

  @doc """
  Evaluates a condition against the given attributes.

  Conditions are MongoDB-query-like expressions.

  ## Available expressions:

  ### Expression groups
  - `$or`: Logical OR
  - `$nor`: Logical OR, but inverted
  - `$and`: Logical AND
  - `$not`: Logical NOT

  ### Simple expressions
  - `$eq`: `left == right`
  - `$ne`: `left != right`
  - `$lt`: `left < right`
  - `$lte`: `left <= right`
  - `$gt`: `left > right`
  - `$gte`: `left >= right`
  - `$exists`: `(left in [nil, :undefined]) != right`
  - `$type`: `typeof left == right`
  - `$regex`: `right |> Regex.compile!() |> Regex.match?(left)`

  ### Array expressions
  - `$in`: `left in right`
  - `$nin`: `left not in right`
  - `$elemMatch`: performs the given condition(s) of left for each element of right (with support for expressions)
  - `$all`: performs the given condition(s) of left for each element of right (without support support for expressions)
  - `$size`: `eval_contition_value(left, length(right))`

  ### Version comparison
  - `$veq`: `versions are equal`
  - `$vne`: `versions are not equal`
  - `$vlt`: `the first version is lesser than the second version`
  - `$vlte`: `the first version is lesser than or equal to the second version`
  - `$vgt`: `the first version is greater than the second version`
  - `$vgte`: `the first version is greater than or equal to the second version`

  ## Examples

      iex> GrowthBook.Condition.eval_condition(%{"hello" => "world"}, %{
      ...>   "hello" => "world"
      ...> })
      true

      iex> GrowthBook.Condition.eval_condition(%{"hello" => "world"}, %{
      ...>   "hello" => "optimizely"
      ...> })
      false
  """
  @spec eval_condition(Context.attributes(), t()) :: boolean()
  def eval_condition(attributes, %{"$or" => conditions}),
    do: eval_or(attributes, conditions)

  def eval_condition(attributes, %{"$nor" => conditions}),
    do: not eval_or(attributes, conditions)

  def eval_condition(attributes, %{"$and" => conditions}),
    do: eval_and(attributes, conditions)

  def eval_condition(attributes, %{"$not" => conditions}),
    do: not eval_condition(attributes, conditions)

  def eval_condition(attributes, conditions) do
    Enum.reduce_while(conditions, true, fn {path, condition}, acc ->
      if eval_condition_value(condition, get_path(attributes, path)) do
        {:cont, acc}
      else
        {:halt, false}
      end
    end)
  end

  @spec eval_condition_value(condition_value(), term()) :: boolean()
  defp eval_condition_value(condition, value) when is_binary(condition),
    do: to_string(value) == condition

  defp eval_condition_value(condition, value) when is_number(condition) and is_number(value),
    do: value == condition

  defp eval_condition_value(condition, value) when is_float(condition) and is_binary(value),
    do: {condition, ""} == Float.parse(value)

  defp eval_condition_value(condition, value) when is_integer(condition) and is_binary(value),
    do: {condition, ""} == Integer.parse(value)

  defp eval_condition_value(condition, value) when is_boolean(condition),
    do: Helpers.cast_boolish(value) == condition

  defp eval_condition_value(nil, value), do: value in [nil, :undefined]

  defp eval_condition_value(condition, value) do
    if is_list(condition) or not operator_object?(condition) do
      condition == value
    else
      Enum.reduce_while(condition, true, fn {operator, expected}, acc ->
        if eval_operator_condition(operator, value, expected) do
          {:cont, acc}
        else
          {:halt, false}
        end
      end)
    end
  end

  @spec eval_operator_condition(String.t(), term(), term()) :: boolean()
  defp eval_operator_condition("$eq", left, right), do: left == right
  defp eval_operator_condition("$ne", left, right), do: left != right

  # Perform JavaScript-like type coercion
  # see https://262.ecma-international.org/5.1/#sec-11.8.5
  @type_coercion_operators ["$lt", "$lte", "$gt", "$gte"]
  defp eval_operator_condition(operator, left, right)
       when is_number(left) and is_binary(right) and operator in @type_coercion_operators do
    case Float.parse(right) do
      {right, _rest} -> eval_operator_condition(operator, left, right)
      _unparseable -> false
    end
  end

  defp eval_operator_condition(operator, left, right)
       when is_number(right) and is_binary(left) and operator in @type_coercion_operators do
    case Float.parse(left) do
      {left, _rest} -> eval_operator_condition(operator, left, right)
      _unparseable -> false
    end
  end

  defp eval_operator_condition(operator, left, right)
       when is_number(right) and is_binary(left) and operator in @type_coercion_operators do
    case Float.parse(left) do
      {left, _rest} -> eval_operator_condition(operator, left, right)
      _unparseable -> false
    end
  end

  defp eval_operator_condition(operator, left, right)
       when is_number(right) and left in [nil, :undefined] and
              operator in @type_coercion_operators do
    eval_operator_condition(operator, 0, right)
  end

  defp eval_operator_condition(operator, left, right)
       when is_number(left) and right in [nil, :undefined] and
              operator in @type_coercion_operators do
    eval_operator_condition(operator, left, 0)
  end

  defp eval_operator_condition("$lt", left, right), do: left < right
  defp eval_operator_condition("$lte", left, right), do: left <= right
  defp eval_operator_condition("$gt", left, right), do: left > right
  defp eval_operator_condition("$gte", left, right), do: left >= right

  defp eval_operator_condition(op, left, right)
       when op in ["$veq", "$vne", "$vlt", "$vlte", "$vgt", "$vgte"] do
    lversion = padded_version(left)
    rversion = padded_version(right)

    case op do
      "$veq" -> lversion == rversion
      "$vne" -> lversion != rversion
      "$vlt" -> lversion < rversion
      "$vlte" -> lversion <= rversion
      "$vgt" -> lversion > rversion
      "$vgte" -> lversion >= rversion
    end
  end

  defp eval_operator_condition("$exists", left, right),
    do: if(right, do: left not in [nil, :undefined], else: left in [nil, :undefined])

  defp eval_operator_condition("$in", _, right) when not is_list(right), do: false

  defp eval_operator_condition("$in", left, right) when is_list(left) do
    Enum.any?(left, &(&1 in right))
  end

  defp eval_operator_condition("$in", left, right), do: left in right
  defp eval_operator_condition("$nin", _, right) when not is_list(right), do: false

  defp eval_operator_condition("$nin", left, right),
    do: not eval_operator_condition("$in", left, right)

  defp eval_operator_condition("$not", left, right), do: not eval_condition_value(right, left)

  defp eval_operator_condition("$size", left, right) when is_list(left),
    do: eval_condition_value(right, length(left))

  defp eval_operator_condition("$size", _left, _right), do: false
  defp eval_operator_condition("$elemMatch", left, right), do: elem_match(left, right)

  defp eval_operator_condition("$all", left, right) when is_list(left) and is_list(right) do
    Enum.reduce_while(right, true, fn condition, acc ->
      if Enum.any?(left, &eval_condition_value(condition, &1)) do
        {:cont, acc}
      else
        {:halt, false}
      end
    end)
  end

  defp eval_operator_condition("$all", _left, _right), do: false

  defp eval_operator_condition("$regex", left, right) do
    case Regex.compile(right) do
      {:ok, regex} -> Regex.match?(regex, left)
      {:error, _err} -> false
    end
  end

  defp eval_operator_condition("$type", left, right), do: get_type(left) == right

  defp eval_operator_condition(operator, _left, _right) do
    IO.warn("Unknown operator: #{operator}")

    false
  end

  @spec elem_match(term(), term()) :: boolean()
  defp elem_match(left, right) when is_list(left) do
    check =
      if operator_object?(right),
        do: &eval_condition_value(right, &1),
        else: &eval_condition(&1, right)

    Enum.reduce_while(left, false, fn value, acc ->
      if check.(value) do
        {:halt, true}
      else
        {:cont, acc}
      end
    end)
  end

  defp elem_match(_left, _right), do: false

  @spec eval_or(Context.attributes(), [t()]) :: boolean()
  defp eval_or(_attributes, []), do: true
  defp eval_or(attributes, [condition]), do: eval_condition(attributes, condition)

  defp eval_or(attributes, [condition | conditions]),
    do: eval_condition(attributes, condition) or eval_or(attributes, conditions)

  @spec eval_and(Context.attributes(), [t()]) :: boolean()
  defp eval_and(_attributes, []), do: true

  defp eval_and(attributes, [condition | conditions]),
    do: eval_condition(attributes, condition) and eval_and(attributes, conditions)

  @spec operator_object?(t()) :: boolean()
  defp operator_object?(condition) when is_map(condition) do
    Enum.all?(condition, fn
      {"$" <> _key, _value} -> true
      _non_operator -> false
    end)
  end

  defp operator_object?(_condition), do: false

  # Given attributes and a dot-separated path string, returns the value of
  # the attribute at the path
  @doc false
  @spec get_path(map(), String.t()) :: term() | :undefined
  def get_path(map, path) do
    path = String.split(path, ".")
    do_get_path(map, path)
  end

  defp do_get_path(value, []), do: value

  defp do_get_path(value, [key | path]) when is_map_key(value, key) do
    %{^key => next_value} = value
    do_get_path(next_value, path)
  end

  defp do_get_path(_value, _path), do: :undefined

  # Returns the data type of the passed argument
  @doc false
  @spec get_type(term()) :: String.t()
  def get_type(attribute_value) when is_binary(attribute_value), do: "string"
  def get_type(attribute_value) when is_number(attribute_value), do: "number"
  def get_type(attribute_value) when is_boolean(attribute_value), do: "boolean"
  def get_type(attribute_value) when is_list(attribute_value), do: "array"
  def get_type(attribute_value) when is_map(attribute_value), do: "object"
  def get_type(attribute_value) when is_nil(attribute_value), do: "null"
  def get_type(:undefined), do: "undefined"
  def get_type(_attribute_value), do: "unknown"

  @doc false
  @spec padded_version(String.t()) :: [String.t()]
  def padded_version("v" <> s), do: padded_version(s)

  def padded_version(s) when is_binary(s) do
    parts =
      String.split(s, "+")
      |> hd()
      |> String.split([".", "-"])
      |> Enum.map(fn s ->
        if Regex.match?(~r/^\d+$/, s),
          do: String.pad_leading(s, 5),
          else: s
      end)

    if length(parts) == 3,
      do: parts ++ ["~"],
      else: parts
  end
end