Merge pull request #12938 from zmstone/0416-variform-add-iif

feat: add conditions to variform expressions
This commit is contained in:
Zaiming (Stone) Shi 2024-04-30 11:56:55 +02:00 committed by GitHub
commit e40d298752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 186 additions and 42 deletions

View File

@ -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).

View File

@ -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

View File

@ -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.

View File

@ -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">>}))
].