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 f199626fdbcdfb1c196af5b2938a0308027bc8d7
Author: James Coglan <[email protected]>
AuthorDate: Tue Jan 13 14:28:28 2026 +0000

    feat: Make mango_selector:match/2 return a list of failures
    
    Rather than returning a boolean to indicate just success or failure,
    `mango_selector:match/2` now returns a list of "failures" describing the
    ways in which the selector failed to match the input. If this list is
    empty, the match was a success.
---
 src/mango/src/mango_selector.erl | 256 ++++++++++++++++++++++-----------------
 1 file changed, 144 insertions(+), 112 deletions(-)

diff --git a/src/mango/src/mango_selector.erl b/src/mango/src/mango_selector.erl
index e651f1565..cfb51d090 100644
--- a/src/mango/src/mango_selector.erl
+++ b/src/mango/src/mango_selector.erl
@@ -23,6 +23,12 @@
 -include_lib("couch/include/couch_db.hrl").
 -include("mango.hrl").
 
+-record(failure, {
+    op,
+    type = mismatch,
+    params = []
+}).
+
 % Validate and normalize each operator. This translates
 % every selector operator into a consistent version that
 % we can then rely on for all other selector functions.
@@ -53,12 +59,19 @@ match(Selector, D) ->
     couch_stats:increment_counter([mango, evaluate_selector]),
     match_int(Selector, D).
 
+match_int(Selector, D) ->
+    case match_failures(Selector, D) of
+        [] -> true;
+        [_ | _] -> false;
+        Other -> Other
+    end.
+
 % An empty selector matches any value.
-match_int({[]}, _) ->
-    true;
-match_int(Selector, #doc{body = Body}) ->
+match_failures({[]}, _) ->
+    [];
+match_failures(Selector, #doc{body = Body}) ->
     match(Selector, Body, fun mango_json:cmp/2);
-match_int(Selector, {Props}) ->
+match_failures(Selector, {Props}) ->
     match(Selector, {Props}, fun mango_json:cmp/2).
 
 % Convert each operator into a normalized version as well
@@ -371,35 +384,45 @@ negate({[{Field, Cond}]}) ->
 % We need to treat an empty array as always true. This will be applied
 % for $or, $in, $all, $nin as well.
 match({[{<<"$and">>, []}]}, _, _) ->
-    true;
+    [];
 match({[{<<"$and">>, Args}]}, Value, Cmp) ->
-    Pred = fun(SubSel) -> match(SubSel, Value, Cmp) end,
-    lists:all(Pred, Args);
+    MatchSubSel = fun(SubSel) -> match(SubSel, Value, Cmp) end,
+    lists:flatmap(MatchSubSel, Args);
 match({[{<<"$or">>, []}]}, _, _) ->
-    true;
+    [];
 match({[{<<"$or">>, Args}]}, Value, Cmp) ->
-    Pred = fun(SubSel) -> match(SubSel, Value, Cmp) end,
-    lists:any(Pred, Args);
+    SubSelFailures = [match(A, Value, Cmp) || A <- Args],
+    case lists:member([], SubSelFailures) of
+        true -> [];
+        _ -> lists:flatten(SubSelFailures)
+    end;
+% TODO: producing good failure messages requires that normalize/1 fully removes
+% $not from the tree by pushing it to the leaves.
 match({[{<<"$not">>, Arg}]}, Value, Cmp) ->
-    not match(Arg, Value, Cmp);
-match({[{<<"$all">>, []}]}, _, _) ->
-    false;
+    case match(Arg, Value, Cmp) of
+        [] -> [#failure{op = 'not'}];
+        _ -> []
+    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, _Cmp) ->
+    % { "$all": [] } is defined to eval to false, so return a failure
+    [#failure{op = all, params = [[]]}];
+match({[{<<"$all">>, [A]}]}, Values, _Cmp) when is_list(A), A == Values ->
+    [];
 match({[{<<"$all">>, Args}]}, Values, _Cmp) when is_list(Values) ->
-    Pred = fun(A) -> lists:member(A, Values) end,
-    HasArgs = lists:all(Pred, Args),
-    IsArgs =
-        case Args of
-            [A] when is_list(A) ->
-                A == Values;
-            _ ->
-                false
+    lists:flatmap(
+        fun(Arg) ->
+            case lists:member(Arg, Values) of
+                true -> [];
+                _ -> [#failure{op = all, params = [Arg]}]
+            end
         end,
-    HasArgs orelse IsArgs;
-match({[{<<"$all">>, _Args}]}, _Values, _Cmp) ->
-    false;
+        Args
+    );
+match({[{<<"$all">>, _}]}, Value, _Cmp) ->
+    [#failure{op = all, type = bad_value, params = [Value]}];
 %% 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:
@@ -416,83 +439,48 @@ match({[{[], Arg}]}, Values, Cmp) ->
     match(Arg, Values, Cmp);
 % Matches when any element in values matches the
 % sub-selector Arg.
+match({[{<<"$elemMatch">>, _Arg}]}, [], _Cmp) ->
+    [#failure{op = elemMatch, type = empty_list}];
 match({[{<<"$elemMatch">>, Arg}]}, Values, Cmp) when is_list(Values) ->
-    try
-        lists:foreach(
-            fun(V) ->
-                case match(Arg, V, Cmp) of
-                    true -> throw(matched);
-                    _ -> ok
-                end
-            end,
-            Values
-        ),
-        false
-    catch
-        throw:matched ->
-            true;
-        _:_ ->
-            false
+    ValueFailures = [match(Arg, V, Cmp) || V <- Values],
+    case lists:member([], ValueFailures) of
+        true -> [];
+        _ -> lists:flatten(ValueFailures)
     end;
-match({[{<<"$elemMatch">>, _Arg}]}, _Value, _Cmp) ->
-    false;
+match({[{<<"$elemMatch">>, _}]}, Value, _Cmp) ->
+    [#failure{op = elemMatch, type = bad_value, params = [Value]}];
 % Matches when all elements in values match the
 % sub-selector Arg.
 match({[{<<"$allMatch">>, Arg}]}, [_ | _] = Values, Cmp) ->
-    try
-        lists:foreach(
-            fun(V) ->
-                case match(Arg, V, Cmp) of
-                    false -> throw(unmatched);
-                    _ -> ok
-                end
-            end,
-            Values
-        ),
-        true
-    catch
-        _:_ ->
-            false
-    end;
-match({[{<<"$allMatch">>, _Arg}]}, _Value, _Cmp) ->
-    false;
+    MatchValue = fun(Value) -> match(Arg, Value, Cmp) end,
+    lists:flatmap(MatchValue, Values);
+match({[{<<"$allMatch">>, _}]}, Value, _Cmp) ->
+    [#failure{op = allMatch, type = bad_value, params = [Value]}];
 % Matches when any key in the map value matches the
 % sub-selector Arg.
-match({[{<<"$keyMapMatch">>, Arg}]}, Value, Cmp) when is_tuple(Value) ->
-    try
-        lists:foreach(
-            fun(V) ->
-                case match(Arg, V, Cmp) of
-                    true -> throw(matched);
-                    _ -> ok
-                end
-            end,
-            [Key || {Key, _} <- element(1, Value)]
-        ),
-        false
-    catch
-        throw:matched ->
-            true;
-        _:_ ->
-            false
+match({[{<<"$keyMapMatch">>, _Arg}]}, {[]}, _Cmp) ->
+    [#failure{op = keyMapMatch, type = empty_list}];
+match({[{<<"$keyMapMatch">>, Arg}]}, {Value}, Cmp) when is_list(Value) ->
+    KeyFailures = [match(Arg, K, Cmp) || {K, _} <- Value],
+    case lists:member([], KeyFailures) of
+        true -> [];
+        _ -> lists:flatten(KeyFailures)
     end;
-match({[{<<"$keyMapMatch">>, _Arg}]}, _Value, _Cmp) ->
-    false;
+match({[{<<"$keyMapMatch">>, _}]}, Value, _Cmp) ->
+    [#failure{op = keyMapMatch, type = bad_value, params = [Value]}];
 % Our comparison operators are fairly straight forward
 match({[{<<"$lt">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) < 0;
+    compare(lt, Arg, Cmp(Value, Arg) < 0);
 match({[{<<"$lte">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) =< 0;
+    compare(lte, Arg, Cmp(Value, Arg) =< 0);
 match({[{<<"$eq">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) == 0;
+    compare(eq, Arg, Cmp(Value, Arg) == 0);
 match({[{<<"$ne">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) /= 0;
+    compare(ne, Arg, Cmp(Value, Arg) /= 0);
 match({[{<<"$gte">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) >= 0;
+    compare(gte, Arg, Cmp(Value, Arg) >= 0);
 match({[{<<"$gt">>, Arg}]}, Value, Cmp) ->
-    Cmp(Value, Arg) > 0;
-match({[{<<"$in">>, []}]}, _, _) ->
-    false;
+    compare(gt, Arg, Cmp(Value, Arg) > 0);
 match({[{<<"$in">>, Args}]}, Values, Cmp) when is_list(Values) ->
     Pred = fun(Arg) ->
         lists:foldl(
@@ -503,50 +491,88 @@ match({[{<<"$in">>, Args}]}, Values, Cmp) when 
is_list(Values) ->
             Values
         )
     end,
-    lists:any(Pred, Args);
+    case lists:any(Pred, Args) of
+        true -> [];
+        _ -> [#failure{op = in, params = [Args]}]
+    end;
 match({[{<<"$in">>, Args}]}, Value, Cmp) ->
     Pred = fun(Arg) -> Cmp(Value, Arg) == 0 end,
-    lists:any(Pred, Args);
-match({[{<<"$nin">>, []}]}, _, _) ->
-    true;
+    case lists:any(Pred, Args) of
+        true -> [];
+        _ -> [#failure{op = in, params = [Args]}]
+    end;
 match({[{<<"$nin">>, Args}]}, Values, Cmp) when is_list(Values) ->
-    not match({[{<<"$in">>, Args}]}, Values, Cmp);
+    Pred = fun(Arg) ->
+        lists:foldl(
+            fun(Value, Match) ->
+                (Cmp(Value, Arg) /= 0) and Match
+            end,
+            true,
+            Values
+        )
+    end,
+    case lists:all(Pred, Args) of
+        true -> [];
+        _ -> [#failure{op = nin, params = [Args]}]
+    end;
 match({[{<<"$nin">>, Args}]}, Value, Cmp) ->
     Pred = fun(Arg) -> Cmp(Value, Arg) /= 0 end,
-    lists:all(Pred, Args);
+    case lists:all(Pred, Args) of
+        true -> [];
+        _ -> [#failure{op = nin, params = [Args]}]
+    end;
 % This logic is a bit subtle. Basically, if value is
 % not undefined, then it exists.
 match({[{<<"$exists">>, ShouldExist}]}, Value, _Cmp) ->
-    Exists = Value /= undefined,
-    ShouldExist andalso Exists;
+    case {ShouldExist, Value} of
+        {true, undefined} -> [#failure{op = exists, params = [ShouldExist]}];
+        {true, _} -> [];
+        {false, undefined} -> [];
+        {false, _} -> [#failure{op = exists, params = [ShouldExist]}]
+    end;
 match({[{<<"$type">>, Arg}]}, Value, _Cmp) when is_binary(Arg) ->
-    Arg == mango_json:type(Value);
+    case mango_json:type(Value) of
+        Arg -> [];
+        _ -> [#failure{op = type, params = [Arg]}]
+    end;
 match({[{<<"$mod">>, [D, R]}]}, Value, _Cmp) when is_integer(Value) ->
-    Value rem D == R;
-match({[{<<"$mod">>, _}]}, _Value, _Cmp) ->
-    false;
+    case Value rem D of
+        R -> [];
+        _ -> [#failure{op = mod, params = [D, R]}]
+    end;
+match({[{<<"$mod">>, _}]}, Value, _Cmp) ->
+    [#failure{op = mod, type = bad_value, params = [Value]}];
 match({[{<<"$beginsWith">>, Prefix}]}, Value, _Cmp) when is_binary(Prefix), 
is_binary(Value) ->
-    string:prefix(Value, Prefix) /= nomatch;
+    case string:prefix(Value, Prefix) of
+        nomatch -> [#failure{op = beginsWith, params = [Prefix]}];
+        _ -> []
+    end;
 % When Value is not a string, do not match
-match({[{<<"$beginsWith">>, Prefix}]}, _, _Cmp) when is_binary(Prefix) ->
-    false;
+match({[{<<"$beginsWith">>, Prefix}]}, Value, _Cmp) when is_binary(Prefix) ->
+    [#failure{op = beginsWith, type = bad_value, params = [Value]}];
 match({[{<<"$regex">>, Regex}]}, Value, _Cmp) when is_binary(Value) ->
     try
-        match == re:run(Value, Regex, [{capture, none}])
+        case re:run(Value, Regex, [{capture, none}]) of
+            match -> [];
+            _ -> [#failure{op = regex, params = [Regex]}]
+        end
     catch
         _:_ ->
-            false
+            [#failure{op = regex, params = [Regex]}]
     end;
-match({[{<<"$regex">>, _}]}, _Value, _Cmp) ->
-    false;
+match({[{<<"$regex">>, _}]}, Value, _Cmp) ->
+    [#failure{op = regex, type = bad_value, params = [Value]}];
 match({[{<<"$size">>, Arg}]}, Values, _Cmp) when is_list(Values) ->
-    length(Values) == Arg;
-match({[{<<"$size">>, _}]}, _Value, _Cmp) ->
-    false;
+    case length(Values) of
+        Arg -> [];
+        _ -> [#failure{op = size, params = [Arg]}]
+    end;
+match({[{<<"$size">>, _}]}, Value, _Cmp) ->
+    [#failure{op = size, type = bad_value, params = [Value]}];
 % We don't have any choice but to believe that the text
 % index returned valid matches
 match({[{<<"$default">>, _}]}, _Value, _Cmp) ->
-    true;
+    [];
 % All other operators are internal assertion errors for
 % matching because we either should've removed them during
 % normalization or something else broke.
@@ -558,11 +584,11 @@ match({[{<<"$", _/binary>> = Op, _}]}, _, _) ->
 match({[{Field, Cond}]}, Value, Cmp) ->
     case mango_doc:get_field(Value, Field) of
         not_found when Cond == {[{<<"$exists">>, false}]} ->
-            true;
+            [];
         not_found ->
-            false;
+            [#failure{op = '$'}];
         bad_path ->
-            false;
+            [#failure{op = '$'}];
         SubValue when Field == <<"_id">> ->
             match(Cond, SubValue, fun mango_json:cmp_raw/2);
         SubValue ->
@@ -571,6 +597,12 @@ match({[{Field, Cond}]}, Value, Cmp) ->
 match({[_, _ | _] = _Props} = Sel, _Value, _Cmp) ->
     error({unnormalized_selector, Sel}).
 
+compare(Op, Arg, Cond) ->
+    case Cond of
+        true -> [];
+        _ -> [#failure{op = Op, params = [Arg]}]
+    end.
+
 % Returns true if Selector requires all
 % fields in RequiredFields to exist in any matching documents.
 

Reply via email to