defmodule Expression.V2.Compat do
@moduledoc """
Compatibility module to make the transition from V1 to V2 a bit easier, hopefully.
It does a few things:
* It swaps out V2 callbacks for V1 callbacks when evaluating expressions with V1.
* It does some patching of the context to match V1's assumptions:
* case insensitive context keys
* casting of integers
* casting of datetimes
* It compares the output of V1 to V2, if those aren't equal it will log an error and return the V1 response.
* If there is no error it will return the value from V2.
> **NOTE**: This module does *twice* the work because it runs V1 and V2 sequentially
and then compares the result before returning a value.
> **NOTE**: This was throwing more errors in prod than anticipated, hacking in a revert temporarily
"""
require Logger
def evaluate_as_string!(
expression,
context,
callback_module \\ Expression.Callbacks.Standard
)
def evaluate_as_string!(expression, context, callback_module) do
v1_resp = Expression.evaluate_as_string!(expression, context, callback_module)
# v2_resp =
# V2.eval_as_string(
# expression,
# V2.Context.new(patch_v1_context(context), callback_module)
# )
# return_or_raise(expression, context, v1_resp, v2_resp)
v1_resp
end
# def v1_module(Turn.Build.Callbacks), do: Turn.Build.CallbacksV1
# def v1_module(V2.Callbacks.Standard), do: Expression.Callbacks.Standard
def patch_v1_key(key),
do:
key
|> to_string()
|> String.downcase()
def patch_v1_context(datetime) when is_struct(datetime, DateTime), do: datetime
def patch_v1_context(date) when is_struct(date, Date), do: date
def patch_v1_context(struct) when is_struct(struct) do
Map.from_struct(struct)
|> patch_v1_context()
end
def patch_v1_context(list) when is_list(list), do: Enum.map(list, &patch_v1_context/1)
def patch_v1_context(map) when is_map(map) do
map
|> Enum.map(fn {key, value} -> {patch_v1_key(key), patch_v1_context(value)} end)
|> Enum.into(%{})
end
def patch_v1_context(binary) when is_binary(binary) do
with :nope <- attempt_integer(binary),
:nope <- attempt_float(binary),
:nope <- attempt_datetime(binary),
:nope <- attempt_boolean(binary) do
binary
end
end
def patch_v1_context(other), do: other
def attempt_boolean(binary) do
potential_boolean =
binary
|> String.trim()
|> String.downcase()
case potential_boolean do
"true" -> true
"false" -> false
_other -> :nope
end
end
# Leading plus is still parsed as an integer, which we don't want
def attempt_integer("+" <> _), do: :nope
# Leading zero likely means a string code, not an integer
def attempt_integer("0" <> binary) when byte_size(binary) > 0, do: :nope
def attempt_integer(binary) do
String.to_integer(binary)
rescue
ArgumentError -> :nope
end
# Leading plus is still parsed as an integer, which we don't want
def attempt_float("+" <> _), do: :nope
def attempt_float(binary) do
String.to_float(binary)
rescue
ArgumentError -> :nope
end
def attempt_datetime(binary) do
case DateTime.from_iso8601(binary) do
{:ok, datetime, _} -> datetime
_other -> :nope
end
end
def evaluate!(expression, context \\ %{}, callback_module \\ Expression.Callbacks.Standard)
def evaluate!(expression, context, callback_module) do
v1_resp = Expression.evaluate!(expression, context, callback_module)
# v2_resp =
# V2.eval(
# expression,
# V2.Context.new(patch_v1_context(context), callback_module)
# )
# |> hd
# return_or_raise(expression, context, v1_resp, v2_resp)
unpack_returned_value(v1_resp)
end
def evaluate_block!(
expression,
context \\ %{},
callback_module \\ Callbacks.Standard
)
def evaluate_block!(expression, context, callback_module) do
v1_resp = Expression.evaluate_block(expression, context, callback_module)
# v2_resp =
# case V2.eval_block(
# expression,
# V2.Context.new(patch_v1_context(context), callback_module)
# ) do
# {:error, error, reason} -> {:error, error <> " " <> reason}
# value -> {:ok, value}
# end
# cond do
# # Hack for handling random returns from `rand_between()` callback function
# # these will throw an error because they're designed to be different every time
# String.contains?(expression, "rand_between") ->
# return_or_raise(expression, context, v2_resp, v2_resp)
# # Hack for handling `@if` expressions, in V2 these aren't evaluated.
# # See the note for this in `eval_compat_test.exs`.
# String.contains?(String.downcase(expression), ["@if", "@left"]) ->
# return_or_raise(expression, context, v2_resp, v2_resp)
# true ->
# return_or_raise(expression, context, v1_resp, v2_resp)
# end
unpack_returned_value(v1_resp)
end
def unpack_returned_value({:ok, val}), do: val
def unpack_returned_value({:error, reason}), do: reason
def unpack_returned_value(other), do: other
def return_or_raise(
_expression,
_context,
{:not_found, _v1_path} = _v1_resp,
nil = _v2_resp
) do
nil
end
def return_or_raise(expression, context, {:ok, val1}, {:ok, val2}) do
return_or_raise(expression, context, val1, val2)
end
def return_or_raise(_expression, _context, {:error, error1}, {:error, _error2}) do
error1
end
def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp)
when byte_size(v1_resp) == 10 do
{:ok, v1_resp} = Date.from_iso8601(v1_resp)
{:ok, v2_resp} = Date.from_iso8601(v2_resp)
return_or_raise(expression, context, v1_resp, v2_resp)
end
def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) do
{:ok, v1_resp, _} = DateTime.from_iso8601(v1_resp)
{:ok, v2_resp, _} = DateTime.from_iso8601(v2_resp)
return_or_raise(expression, context, v1_resp, v2_resp)
end
def return_or_raise(expression, context, v1_resp, v2_resp) do
cond do
is_binary(v1_resp) and is_binary(v2_resp) ->
return_or_raise_binaries(expression, context, v1_resp, v2_resp)
is_struct(v1_resp, DateTime) and is_struct(v2_resp, DateTime) ->
if DateTime.diff(v1_resp, v2_resp) <= :timer.seconds(1) do
v2_resp
else
log_error(expression, context, v1_resp, v2_resp)
end
normalize_value(v1_resp) == normalize_value(v2_resp) ->
v2_resp
true ->
log_error(expression, context, v1_resp, v2_resp)
end
end
# To minimize random errors due to the V1 & V2 expressions being evaluated at different
# times we're truncating DateTime structs to the second to give the CPU some grace
# In the `return_or_raise` we confirm that it's still within a second though and return
# the original (non truncated) value
def normalize_value(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second)
def normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)
def normalize_value(map) when is_map(map) and not is_struct(map) do
map
|> Enum.map(fn {key, value} -> {key, normalize_value(value)} end)
|> Enum.into(%{})
end
def normalize_value(other), do: other
def return_or_raise_binaries(expression, context, v1_resp, v2_resp) do
if String.jaro_distance(v1_resp, v2_resp) > 0.9 do
v2_resp
else
log_error(expression, context, v1_resp, v2_resp)
end
end
def log_error(expression, context, v1_resp, v2_resp) do
Logger.error("""
** Compatibility Error **
Expression: #{inspect(expression)}
Context: #{inspect(Map.drop(context, ["flow"]), pretty: true)}
V1: #{inspect(v1_resp)}
V2: #{inspect(v2_resp)}
""")
v1_resp
end
end