lib/rule_engine/operation/comparison_op.ex

#-------------------------------------------------------------------------------
# Author: Keith Brings
# Copyright (C) 2018 Noizu Labs, Inc. All rights reserved.
#-------------------------------------------------------------------------------

defmodule Noizu.RuleEngine.Op.ComparisonOp do
  @type t :: %__MODULE__{
    name: String.t | nil,
    description: String.t | nil,
    identifier: String.t | list | tuple, # Materialized Path.
    arguments: list,
    comparison: :"==" | :"<>" | :"<=" | :">=" | :"<" | :">",
    comparison_strategy: any,
    settings: Keyword.t,
  }

  defstruct [
    name: nil,
    description: nil,
    identifier: nil,
    arguments: [],
    comparison: :"==",
    comparison_strategy: :default,
    settings: [short_circuit?: :auto, async?: :auto, throw_on_timeout?: :auto]
  ]
end

defimpl Noizu.RuleEngine.ScriptProtocol, for: Noizu.RuleEngine.Op.ComparisonOp do
  alias Noizu.RuleEngine.Helper

  #-----------------
  # execute!/3
  #-----------------
  def execute!(this, state, context), do: execute!(this, state, context, %{})

  #-----------------
  # execute!/4
  #-----------------
  def execute!(this, state, context, options) do
    cond do
      this.settings[:short_circuit?] == :required -> execute!(:short_circuit, this, state, context, options) # Ignore Async settings when short_circuit is mandatory
      Enum.member?([true, :auto, :required], this.settings[:async?]) && (options[:settings] && options.settings.supports_async? == true) -> execute!(:async, this, state, context, options)
      this.settings[:async?] == :required -> throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] Unable to perform required async execute on #{this.__struct__} - #{identifier(this, state, context)}", 310)
      Enum.member?([true, :auto, nil], this.settings[:short_circuit?]) -> execute!(:short_circuit, this, state, context, options)
      true -> execute!(:all, this, state, context, options)
    end
  end

  def perform_comparison(left_arg, right_arg, this, state, context, options) do
    cs = this.comparison_strategy || :default
    cond do
      cs == :default ->
        c = case this.comparison do
          :"==" -> left_arg == right_arg
          :"<>" -> left_arg != right_arg
          :"<" -> left_arg < right_arg
          :">" -> left_arg > right_arg
          :"<=" -> left_arg <= right_arg
          :">=" -> left_arg >= right_arg
        end
        {c, nil}
      match?({_,_,3}, cs) ->
        {m, f, 3} = cs
        {:erlang.apply(m, f, [this.comparison, left_arg, right_arg]), nil}
      match?({_,_,6}, cs) ->
        {m, f, 6} = cs
        :erlang.apply(m, f, [this.comparison, left_arg, right_arg, state, context, options])
      is_function(cs, 3) -> {cs.(this.comparison, left_arg, right_arg), nil}
      is_function(cs, 6) -> cs.(this.comparison, left_arg, right_arg, state, context, options)
      true -> throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} Invalid comparison strategy #{inspect cs}", 404)
    end
  end

  #-----------------
  # execute!/5
  #-----------------
  def execute!(:short_circuit, this, state, context, options) do
    cond do
      length(this.arguments || []) < 2 -> throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} ComparisonOp requires at least 2 arguments", 311)
      true ->
        [h|t] = this.arguments
        p = Noizu.RuleEngine.ScriptProtocol.execute!(h, state, context, options)
        # cs = this.comparison_strategy || :default
        {sentinel, {_o, updated_state}} = Enum.reduce(t, {true, p},
          fn(child, {sentinel, {o, s}}) ->
            if sentinel do
              {c_o, c_s} = Noizu.RuleEngine.ScriptProtocol.execute!(child, s, context, options)
              {c, s} = perform_comparison(o, c_o, this, c_s, context, options)
              {c, {c_o, s || c_s}}
            else
              {sentinel, {o, s}}
            end
          end
        )
        {sentinel, updated_state}
    end
  end

  def execute!(:all, this, state, context, options) do
    cond do
      length(this.arguments || []) < 2 -> throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} ComparisonOp requires at least 2 arguments", 311)
      true ->
        [h|t] = this.arguments
        p = Noizu.RuleEngine.ScriptProtocol.execute!(h, state, context, options)
        #cs = this.comparison_strategy || :default
        {sentinel, {_o, updated_state}} = Enum.reduce(t, {true, p},
          fn(child, {sentinel, {o, s}}) ->
            if sentinel do
              {c_o, c_s} = Noizu.RuleEngine.ScriptProtocol.execute!(child, s, context, options)
              {c, s} = perform_comparison(o, c_o, this, c_s, context, options)
              {c, {c_o, s || c_s}}
            else
              {_, c_s} = Noizu.RuleEngine.ScriptProtocol.execute!(child, s, context, options)
              {sentinel, {o, c_s}}
            end
          end
        )
        {sentinel, updated_state}
    end
  end

  def execute!(:async, this, state, context, options) do
    cond do
      length(this.arguments || []) < 2 -> throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} ComparisonOp requires at least 2 arguments", 311)
      true ->
        yield_wait = this.settings[:timeout] || options[:timeout] || 15_000
        [first_arg|remaining_args] = this.arguments
                                     |> Enum.map(fn(child) -> Task.async(fn -> (Noizu.RuleEngine.ScriptProtocol.execute!(child, state, context, options)) end) end)
                                     |> Task.yield_many(yield_wait)
                                     |> Enum.map(
                                          fn({task, res}) ->
                                            case res do
                                              {:ok, {o, _s}} -> o
                                              _ ->
                                                Task.shutdown(task, yield_wait)
                                                {:error, {Noizu.RuleEngine.ScriptProtocol, {:timeout, task}}}
                                            end
                                          end)

        if match?({:error, {Noizu.RuleEngine.ScriptProtocol, {:timeout, _}}}, first_arg) do
          {:error, {Noizu.RuleEngine.ScriptProtocol, {:timeout, task}}} = first_arg
          throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} Execute Child Task Failed to Complete #{inspect task}", 404)
        end

        {product, _} = Enum.reduce(remaining_args || [], {true, first_arg},
          fn(right_arg, {product, left_arg}) ->
            cond do
              match?({:error, {Noizu.RuleEngine.ScriptProtocol, {:timeout, _}}}, right_arg) ->
                {:error, {Noizu.RuleEngine.ScriptProtocol, {:timeout, task}}} = right_arg
                throw Noizu.RuleEngine.Error.Basic.new("[ScriptError] - #{identifier(this, state, context, options)} Execute Child Task Failed to Complete #{inspect task}", 404)
              product -> {elem(perform_comparison(left_arg, right_arg, this, state, context, options), 0), right_arg}
              true -> {product, nil}
            end
          end)
        {product, state}
    end
  end

  #---------------------
  # identifier/3
  #---------------------
  def identifier(this, _state, _context), do: Helper.identifier(this)

  #---------------------
  # identifier/4
  #---------------------
  def identifier(this, _state, _context, _options), do: Helper.identifier(this)

  #---------------------
  # render/3
  #---------------------
  def render(this, state, context), do: Helper.render_arg_list("CMP #{this.comparison}", identifier(this, state, context), this.arguments || [], state, context, %{})

  #---------------------
  # render/4
  #---------------------
  def render(this, state, context, options), do: Helper.render_arg_list("CMP #{this.comparison}", identifier(this, state, context, options), this.arguments || [], state, context, options)
end