This is an automated email from the ASF dual-hosted git repository. jcoglan pushed a commit to branch mango-match-failures in repository https://gitbox.apache.org/repos/asf/couchdb.git
commit a7fb152283dd798cc6276eafd2b3460241c31444 Author: James Coglan <[email protected]> AuthorDate: Thu Jan 15 16:57:29 2026 +0000 feat: Record the paths where selectors fail to match To give detailed feedback to the caller, the `#ctx` argument to `mango_selector:match/3` now records the path that was taken to reach each value, and this path is added to the `#failure` records. Each path segment is either a binary, if it represents an object property, or an integer if it represents an array index. Items are pushed on the front of `#ctx.path` as this is faster than pushing onto the back of a list. This list can then be reversed once the final list of failures has been generated, before the failures are presented to the caller. --- src/mango/src/mango_selector.erl | 329 ++++++++++++++++++++++++++++++--------- 1 file changed, 256 insertions(+), 73 deletions(-) diff --git a/src/mango/src/mango_selector.erl b/src/mango/src/mango_selector.erl index 8bd1063b4..73db59ca8 100644 --- a/src/mango/src/mango_selector.erl +++ b/src/mango/src/mango_selector.erl @@ -24,13 +24,15 @@ -include("mango.hrl"). -record(ctx, { - cmp + cmp, + path = [] }). -record(failure, { op, type = mismatch, - params = [] + params = [], + path = [] }). % Validate and normalize each operator. This translates @@ -404,29 +406,29 @@ match({[{<<"$or">>, Args}]}, Value, Ctx) -> % $not from the tree by pushing it to the leaves. match({[{<<"$not">>, Arg}]}, Value, Ctx) -> case match(Arg, Value, Ctx) of - [] -> [#failure{op = 'not'}]; + [] -> [#failure{op = 'not', path = Ctx#ctx.path}]; _ -> [] end; % All of the values in Args must exist in Values or % Values == hd(Args) if Args is a single element list % that contains a list. -match({[{<<"$all">>, []}]}, _Values, _Ctx) -> +match({[{<<"$all">>, []}]}, _Values, Ctx) -> % { "$all": [] } is defined to eval to false, so return a failure - [#failure{op = all, params = [[]]}]; + [#failure{op = all, params = [[]], path = Ctx#ctx.path}]; match({[{<<"$all">>, [A]}]}, Values, _Ctx) when is_list(A), A == Values -> []; -match({[{<<"$all">>, Args}]}, Values, _Ctx) when is_list(Values) -> +match({[{<<"$all">>, Args}]}, Values, Ctx) when is_list(Values) -> lists:flatmap( fun(Arg) -> case lists:member(Arg, Values) of true -> []; - _ -> [#failure{op = all, params = [Arg]}] + _ -> [#failure{op = all, params = [Arg], path = Ctx#ctx.path}] end end, Args ); -match({[{<<"$all">>, _}]}, Value, _Ctx) -> - [#failure{op = all, type = bad_value, params = [Value]}]; +match({[{<<"$all">>, _}]}, Value, Ctx) -> + [#failure{op = all, type = bad_value, params = [Value], path = Ctx#ctx.path}]; %% This is for $elemMatch, $allMatch, and possibly $in because of our normalizer. %% A selector such as {"field_name": {"$elemMatch": {"$gte": 80, "$lt": 85}}} %% gets normalized to: @@ -443,49 +445,53 @@ match({[{[], Arg}]}, Values, Ctx) -> match(Arg, Values, Ctx); % Matches when any element in values matches the % sub-selector Arg. -match({[{<<"$elemMatch">>, _Arg}]}, [], _Ctx) -> - [#failure{op = elemMatch, type = empty_list}]; -match({[{<<"$elemMatch">>, Arg}]}, Values, Ctx) when is_list(Values) -> - ValueFailures = [match(Arg, V, Ctx) || V <- Values], +match({[{<<"$elemMatch">>, _Arg}]}, [], Ctx) -> + [#failure{op = elemMatch, type = empty_list, path = Ctx#ctx.path}]; +match({[{<<"$elemMatch">>, Arg}]}, Values, #ctx{path = Path} = Ctx) when is_list(Values) -> + ValueFailures = [ + match(Arg, V, Ctx#ctx{path = [Idx | Path]}) + || {Idx, V} <- lists:enumerate(0, Values) + ], case lists:member([], ValueFailures) of true -> []; _ -> lists:flatten(ValueFailures) end; -match({[{<<"$elemMatch">>, _}]}, Value, _Ctx) -> - [#failure{op = elemMatch, type = bad_value, params = [Value]}]; +match({[{<<"$elemMatch">>, _}]}, Value, Ctx) -> + [#failure{op = elemMatch, type = bad_value, params = [Value], path = Ctx#ctx.path}]; % Matches when all elements in values match the % sub-selector Arg. -match({[{<<"$allMatch">>, Arg}]}, [_ | _] = Values, Ctx) -> - MatchValue = fun(Value) -> match(Arg, Value, Ctx) end, - lists:flatmap(MatchValue, Values); -match({[{<<"$allMatch">>, _}]}, Value, _Ctx) -> - [#failure{op = allMatch, type = bad_value, params = [Value]}]; +match({[{<<"$allMatch">>, Arg}]}, [_ | _] = Values, #ctx{path = Path} = Ctx) -> + EnumValues = lists:enumerate(0, Values), + MatchValue = fun({Idx, Value}) -> match(Arg, Value, Ctx#ctx{path = [Idx | Path]}) end, + lists:flatmap(MatchValue, EnumValues); +match({[{<<"$allMatch">>, _}]}, Value, Ctx) -> + [#failure{op = allMatch, type = bad_value, params = [Value], path = Ctx#ctx.path}]; % Matches when any key in the map value matches the % sub-selector Arg. -match({[{<<"$keyMapMatch">>, _Arg}]}, {[]}, _Ctx) -> - [#failure{op = keyMapMatch, type = empty_list}]; +match({[{<<"$keyMapMatch">>, _Arg}]}, {[]}, Ctx) -> + [#failure{op = keyMapMatch, type = empty_list, path = Ctx#ctx.path}]; match({[{<<"$keyMapMatch">>, Arg}]}, {Value}, Ctx) when is_list(Value) -> KeyFailures = [match(Arg, K, Ctx) || {K, _} <- Value], case lists:member([], KeyFailures) of true -> []; _ -> lists:flatten(KeyFailures) end; -match({[{<<"$keyMapMatch">>, _}]}, Value, _Ctx) -> - [#failure{op = keyMapMatch, type = bad_value, params = [Value]}]; +match({[{<<"$keyMapMatch">>, _}]}, Value, Ctx) -> + [#failure{op = keyMapMatch, type = bad_value, params = [Value], path = Ctx#ctx.path}]; % Our comparison operators are fairly straight forward -match({[{<<"$lt">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(lt, Arg, Cmp(Value, Arg) < 0); -match({[{<<"$lte">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(lte, Arg, Cmp(Value, Arg) =< 0); -match({[{<<"$eq">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(eq, Arg, Cmp(Value, Arg) == 0); -match({[{<<"$ne">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(ne, Arg, Cmp(Value, Arg) /= 0); -match({[{<<"$gte">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(gte, Arg, Cmp(Value, Arg) >= 0); -match({[{<<"$gt">>, Arg}]}, Value, #ctx{cmp = Cmp}) -> - compare(gt, Arg, Cmp(Value, Arg) > 0); -match({[{<<"$in">>, Args}]}, Values, #ctx{cmp = Cmp}) when is_list(Values) -> +match({[{<<"$lt">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(lt, Arg, Path, Cmp(Value, Arg) < 0); +match({[{<<"$lte">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(lte, Arg, Path, Cmp(Value, Arg) =< 0); +match({[{<<"$eq">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(eq, Arg, Path, Cmp(Value, Arg) == 0); +match({[{<<"$ne">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(ne, Arg, Path, Cmp(Value, Arg) /= 0); +match({[{<<"$gte">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(gte, Arg, Path, Cmp(Value, Arg) >= 0); +match({[{<<"$gt">>, Arg}]}, Value, #ctx{cmp = Cmp, path = Path}) -> + compare(gt, Arg, Path, Cmp(Value, Arg) > 0); +match({[{<<"$in">>, Args}]}, Values, #ctx{cmp = Cmp, path = Path}) when is_list(Values) -> Pred = fun(Arg) -> lists:foldl( fun(Value, Match) -> @@ -497,15 +503,15 @@ match({[{<<"$in">>, Args}]}, Values, #ctx{cmp = Cmp}) when is_list(Values) -> end, case lists:any(Pred, Args) of true -> []; - _ -> [#failure{op = in, params = [Args]}] + _ -> [#failure{op = in, params = [Args], path = Path}] end; -match({[{<<"$in">>, Args}]}, Value, #ctx{cmp = Cmp}) -> +match({[{<<"$in">>, Args}]}, Value, #ctx{cmp = Cmp, path = Path}) -> Pred = fun(Arg) -> Cmp(Value, Arg) == 0 end, case lists:any(Pred, Args) of true -> []; - _ -> [#failure{op = in, params = [Args]}] + _ -> [#failure{op = in, params = [Args], path = Path}] end; -match({[{<<"$nin">>, Args}]}, Values, #ctx{cmp = Cmp}) when is_list(Values) -> +match({[{<<"$nin">>, Args}]}, Values, #ctx{cmp = Cmp, path = Path}) when is_list(Values) -> Pred = fun(Arg) -> lists:foldl( fun(Value, Match) -> @@ -517,62 +523,62 @@ match({[{<<"$nin">>, Args}]}, Values, #ctx{cmp = Cmp}) when is_list(Values) -> end, case lists:all(Pred, Args) of true -> []; - _ -> [#failure{op = nin, params = [Args]}] + _ -> [#failure{op = nin, params = [Args], path = Path}] end; -match({[{<<"$nin">>, Args}]}, Value, #ctx{cmp = Cmp}) -> +match({[{<<"$nin">>, Args}]}, Value, #ctx{cmp = Cmp, path = Path}) -> Pred = fun(Arg) -> Cmp(Value, Arg) /= 0 end, case lists:all(Pred, Args) of true -> []; - _ -> [#failure{op = nin, params = [Args]}] + _ -> [#failure{op = nin, params = [Args], path = Path}] end; % This logic is a bit subtle. Basically, if value is % not undefined, then it exists. -match({[{<<"$exists">>, ShouldExist}]}, Value, _Ctx) -> +match({[{<<"$exists">>, ShouldExist}]}, Value, Ctx) -> case {ShouldExist, Value} of - {true, undefined} -> [#failure{op = exists, params = [ShouldExist]}]; + {true, undefined} -> [#failure{op = exists, params = [ShouldExist], path = Ctx#ctx.path}]; {true, _} -> []; {false, undefined} -> []; - {false, _} -> [#failure{op = exists, params = [ShouldExist]}] + {false, _} -> [#failure{op = exists, params = [ShouldExist], path = Ctx#ctx.path}] end; -match({[{<<"$type">>, Arg}]}, Value, _Ctx) when is_binary(Arg) -> +match({[{<<"$type">>, Arg}]}, Value, Ctx) when is_binary(Arg) -> case mango_json:type(Value) of Arg -> []; - _ -> [#failure{op = type, params = [Arg]}] + _ -> [#failure{op = type, params = [Arg], path = Ctx#ctx.path}] end; -match({[{<<"$mod">>, [D, R]}]}, Value, _Ctx) when is_integer(Value) -> +match({[{<<"$mod">>, [D, R]}]}, Value, Ctx) when is_integer(Value) -> case Value rem D of R -> []; - _ -> [#failure{op = mod, params = [D, R]}] + _ -> [#failure{op = mod, params = [D, R], path = Ctx#ctx.path}] end; -match({[{<<"$mod">>, _}]}, Value, _Ctx) -> - [#failure{op = mod, type = bad_value, params = [Value]}]; -match({[{<<"$beginsWith">>, Prefix}]}, Value, _Ctx) when is_binary(Prefix), is_binary(Value) -> +match({[{<<"$mod">>, _}]}, Value, Ctx) -> + [#failure{op = mod, type = bad_value, params = [Value], path = Ctx#ctx.path}]; +match({[{<<"$beginsWith">>, Prefix}]}, Value, Ctx) when is_binary(Prefix), is_binary(Value) -> case string:prefix(Value, Prefix) of - nomatch -> [#failure{op = beginsWith, params = [Prefix]}]; + nomatch -> [#failure{op = beginsWith, params = [Prefix], path = Ctx#ctx.path}]; _ -> [] end; % When Value is not a string, do not match -match({[{<<"$beginsWith">>, Prefix}]}, Value, _Ctx) when is_binary(Prefix) -> - [#failure{op = beginsWith, type = bad_value, params = [Value]}]; -match({[{<<"$regex">>, Regex}]}, Value, _Ctx) when is_binary(Value) -> +match({[{<<"$beginsWith">>, Prefix}]}, Value, Ctx) when is_binary(Prefix) -> + [#failure{op = beginsWith, type = bad_value, params = [Value], path = Ctx#ctx.path}]; +match({[{<<"$regex">>, Regex}]}, Value, Ctx) when is_binary(Value) -> try case re:run(Value, Regex, [{capture, none}]) of match -> []; - _ -> [#failure{op = regex, params = [Regex]}] + _ -> [#failure{op = regex, params = [Regex], path = Ctx#ctx.path}] end catch _:_ -> - [#failure{op = regex, params = [Regex]}] + [#failure{op = regex, params = [Regex], path = Ctx#ctx.path}] end; -match({[{<<"$regex">>, _}]}, Value, _Ctx) -> - [#failure{op = regex, type = bad_value, params = [Value]}]; -match({[{<<"$size">>, Arg}]}, Values, _Ctx) when is_list(Values) -> +match({[{<<"$regex">>, _}]}, Value, Ctx) -> + [#failure{op = regex, type = bad_value, params = [Value], path = Ctx#ctx.path}]; +match({[{<<"$size">>, Arg}]}, Values, Ctx) when is_list(Values) -> case length(Values) of Arg -> []; - _ -> [#failure{op = size, params = [Arg]}] + _ -> [#failure{op = size, params = [Arg], path = Ctx#ctx.path}] end; -match({[{<<"$size">>, _}]}, Value, _Ctx) -> - [#failure{op = size, type = bad_value, params = [Value]}]; +match({[{<<"$size">>, _}]}, Value, Ctx) -> + [#failure{op = size, type = bad_value, params = [Value], path = Ctx#ctx.path}]; % We don't have any choice but to believe that the text % index returned valid matches match({[{<<"$default">>, _}]}, _Value, _Ctx) -> @@ -585,26 +591,33 @@ match({[{<<"$", _/binary>> = Op, _}]}, _, _) -> % We need to traverse value to find field. The call to % mango_doc:get_field/2 may return either not_found or % bad_path in which case matching fails. -match({[{Field, Cond}]}, Value, Ctx) -> +match({[{Field, Cond}]}, Value, #ctx{path = Path} = Ctx) -> + InnerPath = extend_path(Field, Path), + InnerCtx = Ctx#ctx{path = InnerPath}, case mango_doc:get_field(Value, Field) of not_found when Cond == {[{<<"$exists">>, false}]} -> []; not_found -> - [#failure{op = '$'}]; + [#failure{op = field, type = not_found, path = InnerCtx#ctx.path}]; bad_path -> - [#failure{op = '$'}]; + [#failure{op = field, type = bad_path, path = InnerCtx#ctx.path}]; SubValue when Field == <<"_id">> -> - match(Cond, SubValue, Ctx#ctx{cmp = fun mango_json:cmp_raw/2}); + match(Cond, SubValue, InnerCtx#ctx{cmp = fun mango_json:cmp_raw/2}); SubValue -> - match(Cond, SubValue, Ctx) + match(Cond, SubValue, InnerCtx) end; match({[_, _ | _] = _Props} = Sel, _Value, _Ctx) -> error({unnormalized_selector, Sel}). -compare(Op, Arg, Cond) -> +extend_path(Field, Path) when is_binary(Field) -> + [Field | Path]; +extend_path(Field, Path) when is_list(Field) -> + lists:foldl(fun(F, Acc) -> [F | Acc] end, Path, Field). + +compare(Op, Arg, Path, Cond) -> case Cond of true -> []; - _ -> [#failure{op = Op, params = [Arg]}] + _ -> [#failure{op = Op, params = [Arg], path = Path}] end. % Returns true if Selector requires all @@ -1814,4 +1827,174 @@ normalize_nor_test() -> ?assertEqual(true, match_int(Sel2, {[{<<"x">>, [{[{<<"y">>, 4}]}]}]})), ?assertEqual(false, match_int(Sel2, {[{<<"x">>, [{[{<<"y">>, 40}]}]}]})). +match_failures_object_test() -> + Selector = normalize( + {[ + {<<"a">>, 1}, + {<<"b">>, {[{<<"c">>, 3}]}} + ]} + ), + + Fails0 = match_failures( + Selector, + {[ + {<<"a">>, 1}, + {<<"b">>, {[{<<"c">>, 3}]}} + ]} + ), + ?assertEqual([], Fails0), + + Fails1 = match_failures( + Selector, + {[ + {<<"a">>, 0}, + {<<"b">>, {[{<<"c">>, 3}]}} + ]} + ), + ?assertEqual( + [#failure{op = eq, type = mismatch, params = [1], path = [<<"a">>]}], + Fails1 + ), + + Fails2 = match_failures( + Selector, + {[ + {<<"a">>, 1}, + {<<"b">>, {[{<<"c">>, 4}]}} + ]} + ), + ?assertEqual( + [#failure{op = eq, type = mismatch, params = [3], path = [<<"c">>, <<"b">>]}], + Fails2 + ), + + Fails3 = match_failures( + Selector, + {[ + {<<"a">>, 2}, + {<<"b">>, {[{<<"c">>, 4}]}} + ]} + ), + ?assertEqual( + [ + #failure{op = eq, type = mismatch, params = [1], path = [<<"a">>]}, + #failure{op = eq, type = mismatch, params = [3], path = [<<"c">>, <<"b">>]} + ], + Fails3 + ). + +match_failures_elemmatch_test() -> + SelElemMatch = normalize( + {[ + {<<"a">>, + {[ + {<<"$elemMatch">>, {[{<<"$gt">>, 4}]}} + ]}} + ]} + ), + + Fails0 = match_failures( + SelElemMatch, {[{<<"a">>, [5, 3, 2]}]} + ), + ?assertEqual([], Fails0), + + Fails1 = match_failures( + SelElemMatch, {[{<<"a">>, []}]} + ), + ?assertEqual( + [#failure{op = elemMatch, type = empty_list, params = [], path = [<<"a">>]}], + Fails1 + ), + + Fails2 = match_failures( + SelElemMatch, {[{<<"a">>, [3, 2]}]} + ), + ?assertEqual( + [ + #failure{op = gt, type = mismatch, params = [4], path = [0, <<"a">>]}, + #failure{op = gt, type = mismatch, params = [4], path = [1, <<"a">>]} + ], + Fails2 + ). + +match_failures_allmatch_test() -> + SelAllMatch = normalize( + {[ + {<<"a">>, + {[ + {<<"$allMatch">>, {[{<<"$gt">>, 4}]}} + ]}} + ]} + ), + + Fails0 = match_failures( + SelAllMatch, {[{<<"a">>, [5]}]} + ), + ?assertEqual([], Fails0), + + Fails1 = match_failures( + SelAllMatch, {[{<<"a">>, [4]}]} + ), + ?assertEqual( + [#failure{op = gt, type = mismatch, params = [4], path = [0, <<"a">>]}], + Fails1 + ), + + Fails2 = match_failures( + SelAllMatch, {[{<<"a">>, [5, 6, 3, 7, 0]}]} + ), + ?assertEqual( + [ + #failure{op = gt, type = mismatch, params = [4], path = [2, <<"a">>]}, + #failure{op = gt, type = mismatch, params = [4], path = [4, <<"a">>]} + ], + Fails2 + ). + +match_failures_allmatch_object_test() -> + SelAllMatch = normalize( + {[ + {<<"a.b">>, + {[ + {<<"$allMatch">>, {[{<<"c">>, {[{<<"$gt">>, 4}]}}]}} + ]}} + ]} + ), + + Fails0 = match_failures( + SelAllMatch, {[{<<"a">>, {[{<<"b">>, [{[{<<"c">>, 5}]}]}]}}]} + ), + ?assertEqual([], Fails0), + + Fails1 = match_failures( + SelAllMatch, {[{<<"a">>, {[{<<"b">>, [{[{<<"c">>, 4}]}]}]}}]} + ), + ?assertEqual( + [#failure{op = gt, type = mismatch, params = [4], path = [<<"c">>, 0, <<"b">>, <<"a">>]}], + Fails1 + ), + + Fails2 = match_failures( + SelAllMatch, + {[{<<"a">>, {[{<<"b">>, [{[{<<"c">>, 5}]}, {[{<<"c">>, 6}]}, {[{<<"c">>, 3}]}]}]}}]} + ), + ?assertEqual( + [#failure{op = gt, type = mismatch, params = [4], path = [<<"c">>, 2, <<"b">>, <<"a">>]}], + Fails2 + ), + + Fails3 = match_failures( + SelAllMatch, + {[{<<"a">>, {[{<<"b">>, [{[{<<"c">>, 1}]}, {[]}]}]}}]} + ), + ?assertEqual( + [ + #failure{op = gt, type = mismatch, params = [4], path = [<<"c">>, 0, <<"b">>, <<"a">>]}, + #failure{ + op = field, type = not_found, params = [], path = [<<"c">>, 1, <<"b">>, <<"a">>] + } + ], + Fails3 + ). + -endif.
