diff --git a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl index 4e28efb5f..604f43d82 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl +++ b/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). diff --git a/apps/emqx_utils/src/emqx_variform.erl b/apps/emqx_utils/src/emqx_variform.erl index 09a673851..97096559d 100644 --- a/apps/emqx_utils/src/emqx_variform.erl +++ b/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 diff --git a/apps/emqx_utils/src/emqx_variform_bif.erl b/apps/emqx_utils/src/emqx_variform_bif.erl index 5c598efbd..91bd4f9cf 100644 --- a/apps/emqx_utils/src/emqx_variform_bif.erl +++ b/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. diff --git a/apps/emqx_utils/test/emqx_variform_tests.erl b/apps/emqx_utils/test/emqx_variform_tests.erl index 5f9a13326..59c39cf1c 100644 --- a/apps/emqx_utils/test/emqx_variform_tests.erl +++ b/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">>})) + ].