Bladeren bron

feat: add conditions to variform expressions

- refactored `coalesce` function to allow lazy evaluation
- added `iif(Cond, IfExpr, EleseExpr)` to allow simple conditions
zmstone 1 jaar geleden
bovenliggende
commit
c8d6976b14

+ 0 - 6
apps/emqx_rule_engine/src/emqx_rule_funcs.erl

@@ -132,8 +132,6 @@
 
 %% String Funcs
 -export([
-    coalesce/1,
-    coalesce/2,
     lower/1,
     ltrim/1,
     reverse/1,
@@ -759,10 +757,6 @@ is_array(_) -> false.
 %% String Funcs
 %%------------------------------------------------------------------------------
 
-coalesce(List) -> emqx_variform_bif:coalesce(List).
-
-coalesce(A, B) -> emqx_variform_bif:coalesce(A, B).
-
 lower(S) -> emqx_variform_bif:lower(S).
 
 ltrim(S) -> emqx_variform_bif:ltrim(S).

+ 55 - 14
apps/emqx_utils/src/emqx_variform.erl

@@ -42,14 +42,7 @@
         M =:= maps)
 ).
 
--define(COALESCE_BADARG,
-    throw(#{
-        reason => coalesce_badarg,
-        explain =>
-            "must be an array, or a call to a function which returns an array, "
-            "for example: coalesce([a,b,c]) or coalesce(tokens(var,','))"
-    })
-).
+-define(IS_EMPTY(X), (X =:= <<>> orelse X =:= "" orelse X =:= undefined)).
 
 %% @doc Render a variform expression with bindings.
 %% A variform expression is a template string which supports variable substitution
@@ -99,6 +92,7 @@ eval_as_string(Expr, Bindings, _Opts) ->
 return_str(Str) when is_binary(Str) -> Str;
 return_str(Num) when is_integer(Num) -> integer_to_binary(Num);
 return_str(Num) when is_float(Num) -> float_to_binary(Num, [{decimals, 10}, compact]);
+return_str(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
 return_str(Other) ->
     throw(#{
         reason => bad_return,
@@ -133,6 +127,10 @@ decompile(#{expr := Expression}) ->
 decompile(Expression) ->
     Expression.
 
+eval(Atom, _Bindings, _Opts) when is_atom(Atom) ->
+    %% There is no atom literals in variform,
+    %% but some bif functions such as regex_match may return an atom.
+    atom_to_binary(Atom, utf8);
 eval({str, Str}, _Bindings, _Opts) ->
     unicode:characters_to_binary(Str);
 eval({integer, Num}, _Bindings, _Opts) ->
@@ -145,6 +143,8 @@ eval({call, FuncNameStr, Args}, Bindings, Opts) ->
     {Mod, Fun} = resolve_func_name(FuncNameStr),
     ok = assert_func_exported(Mod, Fun, length(Args)),
     case {Mod, Fun} of
+        {?BIF_MOD, iif} ->
+            eval_iif(Args, Bindings, Opts);
         {?BIF_MOD, coalesce} ->
             eval_coalesce(Args, Bindings, Opts);
         _ ->
@@ -158,19 +158,41 @@ eval_loop([H | T], Bindings, Opts) -> [eval(H, Bindings, Opts) | eval_loop(T, Bi
 
 %% coalesce treats var_unbound exception as empty string ''
 eval_coalesce([{array, Args}], Bindings, Opts) ->
-    NewArgs = [lists:map(fun(Arg) -> try_eval(Arg, Bindings, Opts) end, Args)],
-    call(?BIF_MOD, coalesce, NewArgs);
+    %% The input arg is an array
+    eval_coalesce_loop(Args, Bindings, Opts);
 eval_coalesce([Arg], Bindings, Opts) ->
+    %% Arg is an expression (which is expected to return an array)
     case try_eval(Arg, Bindings, Opts) of
         List when is_list(List) ->
-            call(?BIF_MOD, coalesce, List);
+            case lists:dropwhile(fun(I) -> ?IS_EMPTY(I) end, List) of
+                [] ->
+                    <<>>;
+                [H | _] ->
+                    H
+            end;
         <<>> ->
             <<>>;
         _ ->
-            ?COALESCE_BADARG
+            throw(#{
+                reason => coalesce_badarg,
+                explain => "the arg expression did not yield an array"
+            })
     end;
-eval_coalesce(_Args, _Bindings, _Opts) ->
-    ?COALESCE_BADARG.
+eval_coalesce(Args, Bindings, Opts) ->
+    %% It also accepts arbitrary number of args
+    %% equivalent to [{array, Args}]
+    eval_coalesce_loop(Args, Bindings, Opts).
+
+eval_coalesce_loop([], _Bindings, _Opts) ->
+    <<>>;
+eval_coalesce_loop([Arg | Args], Bindings, Opts) ->
+    Result = try_eval(Arg, Bindings, Opts),
+    case ?IS_EMPTY(Result) of
+        true ->
+            eval_coalesce_loop(Args, Bindings, Opts);
+        false ->
+            Result
+    end.
 
 try_eval(Arg, Bindings, Opts) ->
     try
@@ -180,6 +202,21 @@ try_eval(Arg, Bindings, Opts) ->
             <<>>
     end.
 
+eval_iif([Cond, If, Else], Bindings, Opts) ->
+    CondVal = try_eval(Cond, Bindings, Opts),
+    case is_iif_condition_met(CondVal) of
+        true ->
+            eval(If, Bindings, Opts);
+        false ->
+            eval(Else, Bindings, Opts)
+    end.
+
+%% If iif condition expression yielded boolean, use the boolean value.
+%% otherwise it's met as long as it's not an empty string.
+is_iif_condition_met(true) -> true;
+is_iif_condition_met(false) -> false;
+is_iif_condition_met(V) -> not ?IS_EMPTY(V).
+
 %% Some functions accept arbitrary number of arguments but implemented as /1.
 call(Mod, Fun, Args) ->
     erlang:apply(Mod, Fun, Args).
@@ -237,6 +274,10 @@ resolve_var_value(VarName, Bindings, _Opts) ->
             })
     end.
 
+assert_func_exported(?BIF_MOD, coalesce, _Arity) ->
+    ok;
+assert_func_exported(?BIF_MOD, iif, _Arity) ->
+    ok;
 assert_func_exported(Mod, Fun, Arity) ->
     ok = try_load(Mod),
     case erlang:function_exported(Mod, Fun, Arity) of

+ 59 - 18
apps/emqx_utils/src/emqx_variform_bif.erl

@@ -58,9 +58,6 @@
 %% Array functions
 -export([nth/2]).
 
-%% Control functions
--export([coalesce/1, coalesce/2]).
-
 %% Random functions
 -export([rand_str/1, rand_int/1]).
 
@@ -76,26 +73,16 @@
 %% Hash functions
 -export([hash/2, hash_to_range/3, map_to_range/3]).
 
--define(IS_EMPTY(X), (X =:= <<>> orelse X =:= "" orelse X =:= undefined)).
+%% String compare functions
+-export([str_comp/2, str_eq/2, str_lt/2, str_lte/2, str_gt/2, str_gte/2]).
+
+%% Number compare functions
+-export([num_comp/2, num_eq/2, num_lt/2, num_lte/2, num_gt/2, num_gte/2]).
 
 %%------------------------------------------------------------------------------
 %% String Funcs
 %%------------------------------------------------------------------------------
 
-%% @doc Return the first non-empty string
-coalesce(A, B) when ?IS_EMPTY(A) andalso ?IS_EMPTY(B) ->
-    <<>>;
-coalesce(A, B) when ?IS_EMPTY(A) ->
-    B;
-coalesce(A, _B) ->
-    A.
-
-%% @doc Return the first non-empty string
-coalesce([]) ->
-    <<>>;
-coalesce([H | T]) ->
-    coalesce(H, coalesce(T)).
-
 lower(S) when is_binary(S) ->
     string:lowercase(S).
 
@@ -523,3 +510,57 @@ map_to_range(Int, Min, Max) when
     Min + (Int rem Range);
 map_to_range(_, _, _) ->
     throw(#{reason => badarg, function => ?FUNCTION_NAME}).
+
+compare(A, A) -> eq;
+compare(A, B) when A < B -> lt;
+compare(_A, _B) -> gt.
+
+%% @doc Compare two strings, returns
+%% - 'eq' if they are the same.
+%% - 'lt' if arg-1 is ordered before arg-2
+%% - `gt` if arg-1 is ordered after arg-2
+str_comp(A0, B0) ->
+    A = any_to_str(A0),
+    B = any_to_str(B0),
+    compare(A, B).
+
+%% @doc Return 'true' if two strings are the same, otherwise 'false'.
+str_eq(A, B) -> eq =:= str_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is ordered before arg-2, otherwise 'false'.
+str_lt(A, B) -> lt =:= str_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is ordered after arg-2, otherwise 'false'.
+str_gt(A, B) -> gt =:= str_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is not ordered after arg-2, otherwise 'false'.
+str_lte(A, B) ->
+    R = str_comp(A, B),
+    R =:= lt orelse R =:= eq.
+
+%% @doc Return 'true' if arg-1 is not ordered bfore arg-2, otherwise 'false'.
+str_gte(A, B) ->
+    R = str_comp(A, B),
+    R =:= gt orelse R =:= eq.
+
+num_comp(A, B) when is_number(A) andalso is_number(B) ->
+    compare(A, B).
+
+%% @doc Return 'true' if two numbers are the same, otherwise 'false'.
+num_eq(A, B) -> eq =:= num_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is ordered before arg-2, otherwise 'false'.
+num_lt(A, B) -> lt =:= num_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is ordered after arg-2, otherwise 'false'.
+num_gt(A, B) -> gt =:= num_comp(A, B).
+
+%% @doc Return 'true' if arg-1 is not ordered after arg-2, otherwise 'false'.
+num_lte(A, B) ->
+    R = num_comp(A, B),
+    R =:= lt orelse R =:= eq.
+
+%% @doc Return 'true' if arg-1 is not ordered bfore arg-2, otherwise 'false'.
+num_gte(A, B) ->
+    R = num_comp(A, B),
+    R =:= gt orelse R =:= eq.

+ 72 - 4
apps/emqx_utils/test/emqx_variform_tests.erl

@@ -151,6 +151,9 @@ coalesce_test_() ->
         {"arg from other func", fun() ->
             ?assertEqual({ok, <<"b">>}, render("coalesce(tokens(a,','))", #{a => <<",,b,c">>}))
         end},
+        {"arg from other func, but no result", fun() ->
+            ?assertEqual({ok, <<"">>}, render("coalesce(tokens(a,','))", #{a => <<",,,">>}))
+        end},
         {"var unbound", fun() -> ?assertEqual({ok, <<>>}, render("coalesce(a)", #{})) end},
         {"var unbound in call", fun() ->
             ?assertEqual({ok, <<>>}, render("coalesce(concat(a))", #{}))
@@ -158,18 +161,70 @@ coalesce_test_() ->
         {"var unbound in calls", fun() ->
             ?assertEqual({ok, <<"c">>}, render("coalesce([any_to_str(a),any_to_str(b),'c'])", #{}))
         end},
-        {"badarg", fun() ->
-            ?assertMatch(
-                {error, #{reason := coalesce_badarg}}, render("coalesce(a,b)", #{a => 1, b => 2})
+        {"coalesce n-args", fun() ->
+            ?assertEqual(
+                {ok, <<"2">>}, render("coalesce(a,b)", #{a => <<"">>, b => 2})
             )
         end},
-        {"badarg from return", fun() ->
+        {"coalesce 1-arg", fun() ->
             ?assertMatch(
                 {error, #{reason := coalesce_badarg}}, render("coalesce(any_to_str(a))", #{a => 1})
             )
         end}
     ].
 
+compare_string_test_() ->
+    [
+        %% Testing str_eq/2
+        ?_assertEqual({ok, <<"true">>}, render("str_eq('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_eq('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_eq('', '')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_eq('a', '')", #{})),
+
+        %% Testing str_lt/2
+        ?_assertEqual({ok, <<"true">>}, render("str_lt('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_lt('b', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_lt('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_lt('', '')", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("str_gt('b', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_gt('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_gt('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_gt('', '')", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("str_lte('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_lte('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_lte('b', 'a')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_lte('', '')", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("str_gte('b', 'a')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_gte('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_gte('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_gte('', '')", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("str_gt(9, 10)", #{}))
+    ].
+
+compare_numbers_test_() ->
+    [
+        ?_assertEqual({ok, <<"true">>}, render("num_eq(1, 1)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_eq(2, 1)", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("num_lt(1, 2)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_lt(2, 2)", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("num_gt(2, 1)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_gt(1, 1)", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("num_lte(1, 1)", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("num_lte(1, 2)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_lte(2, 1)", #{})),
+
+        ?_assertEqual({ok, <<"true">>}, render("num_gte(2, -1)", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("num_gte(2, 2)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_gte(-1, 2)", #{}))
+    ].
+
 syntax_error_test_() ->
     [
         {"empty expression", fun() -> ?assertMatch(?SYNTAX_ERROR, render("", #{})) end},
@@ -218,3 +273,16 @@ to_range_badarg_test_() ->
         ?ASSERT_BADARG(map_to_range, "('a','1',2)"),
         ?ASSERT_BADARG(map_to_range, "('a',2,1)")
     ].
+
+iif_test_() ->
+    %% if clientid has to words separated by a -, take the suffix, and append with `/#`
+    Expr1 = "iif(nth(2,tokens(clientid,'-')),concat([nth(2,tokens(clientid,'-')),'/#']),'')",
+    [
+        ?_assertEqual({ok, <<"yes-A">>}, render("iif(a,'yes-A','no-A')", #{a => <<"x">>})),
+        ?_assertEqual({ok, <<"no-A">>}, render("iif(a,'yes-A','no-A')", #{})),
+        ?_assertEqual({ok, <<"2">>}, render("iif(str_eq(a,1),2,3)", #{a => 1})),
+        ?_assertEqual({ok, <<"3">>}, render("iif(str_eq(a,1),2,3)", #{a => <<"not-1">>})),
+        ?_assertEqual({ok, <<"3">>}, render("iif(str_eq(a,1),2,3)", #{})),
+        ?_assertEqual({ok, <<"">>}, render(Expr1, #{clientid => <<"a">>})),
+        ?_assertEqual({ok, <<"suffix/#">>}, render(Expr1, #{clientid => <<"a-suffix">>}))
+    ].