You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by rn...@apache.org on 2023/04/05 08:28:39 UTC

[couchdb] branch main updated: treat single-element keys as the key for `_view`

This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/couchdb.git


The following commit(s) were added to refs/heads/main by this push:
     new 23f4e628d treat single-element keys as the key for `_view`
     new 31e3f64e6 Merge pull request #4494 from jiahuili430/treat-key-as-keys
23f4e628d is described below

commit 23f4e628da4c79c6da4036aa1ebe7a01ff5cc949
Author: jiahuili <li...@gmail.com>
AuthorDate: Tue Mar 21 15:35:14 2023 -0500

    treat single-element keys as the key for `_view`
    
    - Request `_view` with reduce function, treat single-element `keys` as `key`
    - Add treat_single_keys_as_key/2 function for POST `_view`.
      If we query `_all_docs` for deleted or nonexistent docs using single-element
      keys, we can get {deleted:true} and {error:not_found}.
    - Add documentation on using key, keys, start_key, and end_key
---
 src/chttpd/test/eunit/chttpd_view_test.erl         | 473 ++++++++++++++++-----
 src/couch_mrview/src/couch_mrview_http.erl         |  37 +-
 src/couch_mrview/src/couch_mrview_util.erl         |   9 +-
 .../test/eunit/couch_mrview_all_docs_tests.erl     |  16 +
 .../test/eunit/couch_mrview_http_tests.erl         |  29 ++
 src/docs/src/api/ddoc/views.rst                    |  85 +++-
 6 files changed, 518 insertions(+), 131 deletions(-)

diff --git a/src/chttpd/test/eunit/chttpd_view_test.erl b/src/chttpd/test/eunit/chttpd_view_test.erl
index ceff2a902..e478e6640 100644
--- a/src/chttpd/test/eunit/chttpd_view_test.erl
+++ b/src/chttpd/test/eunit/chttpd_view_test.erl
@@ -19,45 +19,47 @@
 -define(PASS, "pass").
 -define(AUTH, {basic_auth, {?USER, ?PASS}}).
 -define(CONTENT_JSON, {"Content-Type", "application/json"}).
--define(DDOC,
-    "{\"_id\": \"_design/bar\", \"views\": {\"baz\":\n"
-    "               {\"map\": \"function(doc) {emit(doc._id, doc._id);}\"}}}"
-).
 
--define(FIXTURE_TXT, ?ABS_PATH(?FILE)).
--define(i2l(I), integer_to_list(I)).
+-define(DOCS, #{
+    <<"docs">> => [
+        #{<<"_id">> => <<"a">>, <<"key">> => <<"a">>, <<"value">> => 1},
+        #{<<"_id">> => <<"b">>, <<"key">> => <<"b">>, <<"value">> => 2},
+        #{<<"_id">> => <<"c">>, <<"key">> => <<"c">>, <<"value">> => 3},
+        #{<<"_id">> => <<"d">>, <<"key">> => <<"d">>, <<"_deleted">> => true}
+    ]
+}).
+-define(DDOC, #{
+    <<"_id">> => <<"_design/ddoc">>,
+    <<"views">> => #{
+        <<"map">> => #{<<"map">> => <<"function(doc) { emit(doc.key, doc.value) }">>},
+        <<"map_reduce">> => #{
+            <<"map">> => <<"function(doc) { emit(doc.key, doc.value) }">>,
+            <<"reduce">> => <<"_sum">>
+        }
+    }
+}).
+-define(ERROR_KEYS_INCOMPATIBLE, #{
+    <<"error">> := <<"query_parse_error">>,
+    <<"reason">> := <<"`keys` is incompatible with `key`, `start_key` and `end_key`">>
+}).
+
 % seconds
 -define(TIMEOUT, 60).
 
 setup() ->
     Hashed = couch_passwords:hash_admin_password(?PASS),
     ok = config:set("admins", ?USER, ?b2l(Hashed), _Persist = false),
-    TmpDb = ?tempdb(),
-    Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
-    Port = mochiweb_socket_server:get(chttpd, port),
-    Url = lists:concat(["http://", Addr, ":", Port, "/", ?b2l(TmpDb)]),
-    create_db(Url),
-    Url.
+    Db = ?tempdb(),
+    ok = create_db(Db),
+    ok = create_docs(Db),
+    ok = create_ddoc(Db),
+    Db.
 
-teardown(Url) ->
-    delete_db(Url),
+teardown(Db) ->
+    ok = fabric:delete_db(Db),
     ok = config:delete("admins", ?USER, _Persist = false).
 
-create_db(Url) ->
-    {ok, Status, _, _} = test_request:put(Url, [?CONTENT_JSON, ?AUTH], "{}"),
-    ?assert(Status =:= 201 orelse Status =:= 202).
-
-create_doc(Url, Id) ->
-    test_request:put(
-        Url ++ "/" ++ Id,
-        [?CONTENT_JSON, ?AUTH],
-        "{\"mr\": \"rockoartischocko\"}"
-    ).
-
-delete_db(Url) ->
-    {ok, 200, _, _} = test_request:delete(Url, [?AUTH]).
-
-all_view_test_() ->
+view_test_() ->
     {
         "chttpd view tests",
         {
@@ -69,86 +71,343 @@ all_view_test_() ->
                 fun setup/0,
                 fun teardown/1,
                 [
-                    fun should_succeed_on_view_with_queries_keys/1,
-                    fun should_succeed_on_view_with_queries_limit_skip/1,
-                    fun should_succeed_on_view_with_multiple_queries/1
+                    ?TDEF_FE(t_view_with_queries_keys, ?TIMEOUT),
+                    ?TDEF_FE(t_view_with_queries_limit_skip, ?TIMEOUT),
+                    ?TDEF_FE(t_view_with_multiple_queries, ?TIMEOUT),
+                    ?TDEF_FE(t_view_with_key_and_start_key),
+                    ?TDEF_FE(t_view_with_key_and_end_key),
+                    ?TDEF_FE(t_view_with_single_keys_and_start_key),
+                    ?TDEF_FE(t_view_with_keys_and_start_key),
+                    ?TDEF_FE(t_view_with_key_non_existent_docs),
+                    ?TDEF_FE(t_view_with_keys_non_existent_docs),
+                    ?TDEF_FE(t_view_with_key_deleted_docs),
+                    ?TDEF_FE(t_view_with_keys_deleted_docs),
+                    ?TDEF_FE(t_view_map_reduce_with_key),
+                    ?TDEF_FE(t_view_map_reduce_with_single_keys),
+                    ?TDEF_FE(t_view_map_reduce_with_single_keys_and_group),
+                    ?TDEF_FE(t_view_map_reduce_with_keys),
+                    ?TDEF_FE(t_view_map_reduce_with_keys_and_group)
+                ]
+            }
+        }
+    }.
+
+all_docs_test_() ->
+    {
+        "chttpd all docs tests",
+        {
+            setup,
+            fun chttpd_test_util:start_couch/0,
+            fun chttpd_test_util:stop_couch/1,
+            {
+                foreach,
+                fun setup/0,
+                fun teardown/1,
+                [
+                    ?TDEF_FE(t_all_docs_with_key_and_start_key),
+                    ?TDEF_FE(t_all_docs_with_key_and_end_key),
+                    ?TDEF_FE(t_all_docs_with_single_keys_and_start_key),
+                    ?TDEF_FE(t_all_docs_with_keys_and_start_key),
+                    ?TDEF_FE(t_all_docs_with_key_non_existent_docs),
+                    ?TDEF_FE(t_all_docs_with_keys_non_existent_docs),
+                    ?TDEF_FE(t_all_docs_with_key_deleted_docs),
+                    ?TDEF_FE(t_all_docs_with_keys_deleted_docs)
                 ]
             }
         }
     }.
 
-should_succeed_on_view_with_queries_keys(Url) ->
-    {timeout, ?TIMEOUT,
-        ?_test(begin
-            [create_doc(Url, "testdoc" ++ ?i2l(I)) || I <- lists:seq(1, 10)],
-            {ok, _, _, _} = test_request:put(
-                Url ++ "/_design/bar",
-                [?CONTENT_JSON, ?AUTH],
-                ?DDOC
-            ),
-            QueryDoc =
-                "{\"queries\": [{\"keys\": [ \"testdoc3\",\n"
-                "            \"testdoc8\"]}]}",
-            {ok, _, _, RespBody} = test_request:post(
-                Url ++ "/_design/bar/" ++
-                    "_view/baz/queries/",
-                [?CONTENT_JSON, ?AUTH],
-                QueryDoc
-            ),
-            {ResultJson} = ?JSON_DECODE(RespBody),
-            ResultJsonBody = couch_util:get_value(<<"results">>, ResultJson),
-            {InnerJson} = lists:nth(1, ResultJsonBody),
-            ?assertEqual(2, length(couch_util:get_value(<<"rows">>, InnerJson)))
-        end)}.
-
-should_succeed_on_view_with_queries_limit_skip(Url) ->
-    {timeout, ?TIMEOUT,
-        ?_test(begin
-            [create_doc(Url, "testdoc" ++ ?i2l(I)) || I <- lists:seq(1, 10)],
-            {ok, _, _, _} = test_request:put(
-                Url ++ "/_design/bar",
-                [?CONTENT_JSON, ?AUTH],
-                ?DDOC
-            ),
-            QueryDoc = "{\"queries\": [{\"limit\": 5, \"skip\": 2}]}",
-            {ok, RC, _, RespBody} = test_request:post(
-                Url ++ "/_design/bar/" ++
-                    "_view/baz/queries/",
-                [?CONTENT_JSON, ?AUTH],
-                QueryDoc
-            ),
-            ?assertEqual(200, RC),
-            {ResultJson} = ?JSON_DECODE(RespBody),
-            ResultJsonBody = couch_util:get_value(<<"results">>, ResultJson),
-            {InnerJson} = lists:nth(1, ResultJsonBody),
-            ?assertEqual(2, couch_util:get_value(<<"offset">>, InnerJson)),
-            ?assertEqual(5, length(couch_util:get_value(<<"rows">>, InnerJson)))
-        end)}.
-
-should_succeed_on_view_with_multiple_queries(Url) ->
-    {timeout, ?TIMEOUT,
-        ?_test(begin
-            [create_doc(Url, "testdoc" ++ ?i2l(I)) || I <- lists:seq(1, 10)],
-            {ok, _, _, _} = test_request:put(
-                Url ++ "/_design/bar",
-                [?CONTENT_JSON, ?AUTH],
-                ?DDOC
-            ),
-            QueryDoc =
-                "{\"queries\": [{\"keys\": [ \"testdoc3\",\n"
-                "            \"testdoc8\"]}, {\"limit\": 5, \"skip\": 2}]}",
-            {ok, RC, _, RespBody} = test_request:post(
-                Url ++ "/_design/bar/" ++
-                    "_view/baz/queries/",
-                [?CONTENT_JSON, ?AUTH],
-                QueryDoc
-            ),
-            ?assertEqual(200, RC),
-            {ResultJson} = ?JSON_DECODE(RespBody),
-            ResultJsonBody = couch_util:get_value(<<"results">>, ResultJson),
-            {InnerJson1} = lists:nth(1, ResultJsonBody),
-            ?assertEqual(2, length(couch_util:get_value(<<"rows">>, InnerJson1))),
-            {InnerJson2} = lists:nth(2, ResultJsonBody),
-            ?assertEqual(2, couch_util:get_value(<<"offset">>, InnerJson2)),
-            ?assertEqual(5, length(couch_util:get_value(<<"rows">>, InnerJson2)))
-        end)}.
+t_view_with_queries_keys(Db) ->
+    QueryDoc = #{<<"queries">> => [#{<<"keys">> => [<<"a">>, <<"c">>]}]},
+    {Code, Res} = req(post, url(Db, "_design/ddoc/_view/map/queries"), QueryDoc),
+    ?assertMatch(
+        #{
+            <<"results">> := [
+                #{
+                    <<"total_rows">> := 3,
+                    <<"offset">> := 1,
+                    <<"rows">> := [#{<<"id">> := <<"a">>}, #{<<"id">> := <<"c">>}]
+                }
+            ]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code).
+
+t_view_with_queries_limit_skip(Db) ->
+    QueryDoc = #{<<"queries">> => [#{<<"limit">> => 1, <<"skip">> => 1}]},
+    {Code, Res} = req(post, url(Db, "_design/ddoc/_view/map/queries/"), QueryDoc),
+    ?assertMatch(
+        #{
+            <<"results">> := [
+                #{<<"total_rows">> := 3, <<"offset">> := 1, <<"rows">> := [#{<<"id">> := <<"b">>}]}
+            ]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code).
+
+t_view_with_multiple_queries(Db) ->
+    QueryDoc = #{
+        <<"queries">> => [#{<<"keys">> => [<<"a">>, <<"c">>], <<"limit">> => 1, <<"skip">> => 1}]
+    },
+    {Code, Res} = req(post, url(Db, "_design/ddoc/_view/map/queries/"), QueryDoc),
+    ?assertMatch(
+        #{
+            <<"results">> := [
+                #{<<"total_rows">> := 3, <<"offset">> := 2, <<"rows">> := [#{<<"id">> := <<"c">>}]}
+            ]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code).
+
+t_view_with_key_and_start_key(Db) ->
+    {Code1, Res1} = req(get, url(Db, "_design/ddoc/_view/map", "key=\"a\"&startkey=\"b\"")),
+    {Code2, Res2} = req(get, url(Db, "_design/ddoc/_view/map", "startkey=\"b\"&key=\"a\"")),
+    ?assertMatch(
+        #{
+            <<"error">> := <<"query_parse_error">>,
+            <<"reason">> :=
+                <<"No rows can match your key range, reverse your start_key and end_key or set descending=true">>
+        },
+        Res1
+    ),
+    ?assertMatch(#{<<"rows">> := [#{<<"id">> := <<"a">>}]}, Res2),
+    ?assertEqual(400, Code1),
+    ?assertEqual(200, Code2).
+
+t_all_docs_with_key_and_start_key(Db) ->
+    {Code1, Res1} = req(get, url(Db, "_all_docs", "key=\"a\"&startkey=\"b\"")),
+    {Code2, Res2} = req(get, url(Db, "_all_docs", "startkey=\"b\"&key=\"a\"")),
+    ?assertMatch(#{<<"rows">> := []}, Res1),
+    ?assertMatch(#{<<"rows">> := [#{<<"id">> := <<"a">>}]}, Res2),
+    ?assertEqual(200, Code1),
+    ?assertEqual(200, Code2).
+
+t_view_with_key_and_end_key(Db) ->
+    test_helper_key_and_end_key(Db, "_design/ddoc/_view/map").
+
+t_all_docs_with_key_and_end_key(Db) ->
+    test_helper_key_and_end_key(Db, "_all_docs").
+
+test_helper_key_and_end_key(Db, Path) ->
+    {Code1, Res1} = req(get, url(Db, Path, "key=\"a\"&endkey=\"b\"")),
+    {Code2, Res2} = req(get, url(Db, Path, "endkey=\"b\"&key=\"a\"")),
+    ?assertMatch(#{<<"rows">> := [#{<<"id">> := <<"a">>}, #{<<"id">> := <<"b">>}]}, Res1),
+    ?assertMatch(#{<<"rows">> := [#{<<"id">> := <<"a">>}]}, Res2),
+    ?assertEqual(200, Code1),
+    ?assertEqual(200, Code2).
+
+t_view_with_single_keys_and_start_key(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map?keys=[\"a\"]&startkey=\"b\"")),
+    ?assertMatch(
+        #{
+            <<"error">> := <<"query_parse_error">>,
+            <<"reason">> :=
+                <<"No rows can match your key range, reverse your start_key and end_key or set descending=true">>
+        },
+        Res
+    ),
+    ?assertEqual(400, Code).
+
+t_all_docs_with_single_keys_and_start_key(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs?keys=[\"a\"]&startkey=\"b\"")),
+    ?assertMatch(?ERROR_KEYS_INCOMPATIBLE, Res),
+    ?assertEqual(400, Code).
+
+t_view_with_keys_and_start_key(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map", "keys=[\"a\",\"b\"]&start_key=\"b\"")),
+    ?assertMatch(?ERROR_KEYS_INCOMPATIBLE, Res),
+    ?assertEqual(400, Code).
+
+t_all_docs_with_keys_and_start_key(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs", "keys=[\"a\",\"b\"]&start_key=\"b\"")),
+    ?assertMatch(?ERROR_KEYS_INCOMPATIBLE, Res),
+    ?assertEqual(400, Code).
+
+t_view_with_key_non_existent_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map", "key=\"not_exist\"")),
+    ?assertMatch(#{<<"total_rows">> := 3, <<"offset">> := 3, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map"), #{<<"key">> => <<"not_exist">>}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_all_docs_with_key_non_existent_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs", "key=\"not_exist\"")),
+    ?assertMatch(#{<<"total_rows">> := 4, <<"offset">> := 4, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_all_docs"), #{<<"key">> => <<"not_exist">>}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_with_keys_non_existent_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map", "keys=[\"not_exist\"]")),
+    ?assertMatch(#{<<"total_rows">> := 3, <<"offset">> := 3, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map"), #{<<"keys">> => [<<"not_exist">>]}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_all_docs_with_keys_non_existent_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs", "keys=[\"not_exist\"]")),
+    ?assertMatch(
+        #{
+            <<"total_rows">> := 4,
+            <<"offset">> := null,
+            <<"rows">> := [#{<<"key">> := <<"not_exist">>, <<"error">> := <<"not_found">>}]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_all_docs"), #{<<"keys">> => [<<"not_exist">>]}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_with_key_deleted_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map", "key=\"d\"")),
+    ?assertMatch(#{<<"total_rows">> := 3, <<"offset">> := 3, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map"), #{<<"key">> => <<"d">>}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_all_docs_with_key_deleted_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs", "key=\"d\"")),
+    ?assertMatch(#{<<"total_rows">> := 4, <<"offset">> := 4, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_all_docs"), #{<<"key">> => <<"d">>}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_with_keys_deleted_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map", "keys=[\"d\"]")),
+    ?assertMatch(#{<<"total_rows">> := 3, <<"offset">> := 3, <<"rows">> := []}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map"), #{<<"keys">> => [<<"d">>]}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_all_docs_with_keys_deleted_docs(Db) ->
+    {Code, Res} = req(get, url(Db, "_all_docs", "keys=[\"d\"]")),
+    ?assertMatch(
+        #{
+            <<"total_rows">> := 4,
+            <<"offset">> := null,
+            <<"rows">> := [
+                #{
+                    <<"id">> := <<"d">>,
+                    <<"key">> := <<"d">>,
+                    <<"value">> := #{<<"deleted">> := true}
+                }
+            ]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_all_docs"), #{<<"keys">> => [<<"d">>]}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_map_reduce_with_key(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map_reduce", "key=\"a\"")),
+    ?assertMatch(#{<<"rows">> := [#{<<"key">> := null, <<"value">> := 1}]}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map_reduce"), #{<<"key">> => <<"a">>}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_map_reduce_with_single_keys(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map_reduce", "keys=[\"a\"]")),
+    ?assertMatch(#{<<"rows">> := [#{<<"key">> := null, <<"value">> := 1}]}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map_reduce"), #{<<"keys">> => [<<"a">>]}),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_map_reduce_with_single_keys_and_group(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map_reduce?keys=[\"a\"]&group=true")),
+    ?assertMatch(#{<<"rows">> := [#{<<"key">> := <<"a">>, <<"value">> := 1}]}, Res),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map_reduce"), #{
+        <<"keys">> => [<<"a">>], <<"group">> => <<"true">>
+    }),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_map_reduce_with_keys(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map_reduce?keys=[\"a\",\"b\"]")),
+    ?assertMatch(
+        #{
+            <<"error">> := <<"query_parse_error">>,
+            <<"reason">> := <<"Multi-key fetches for reduce views must use `group=true`">>
+        },
+        Res
+    ),
+    ?assertEqual(400, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map_reduce"), #{
+        <<"keys">> => [<<"a">>, <<"b">>]
+    }),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+t_view_map_reduce_with_keys_and_group(Db) ->
+    {Code, Res} = req(get, url(Db, "_design/ddoc/_view/map_reduce?keys=[\"a\",\"b\"]&group=true")),
+    ?assertMatch(
+        #{
+            <<"rows">> := [
+                #{<<"key">> := <<"a">>, <<"value">> := 1},
+                #{<<"key">> := <<"b">>, <<"value">> := 2}
+            ]
+        },
+        Res
+    ),
+    ?assertEqual(200, Code),
+    {Code1, Res1} = req(post, url(Db, "_design/ddoc/_view/map_reduce"), #{
+        <<"keys">> => [<<"a">>, <<"b">>], <<"group">> => true
+    }),
+    ?assertEqual(Res, Res1),
+    ?assertEqual(Code, Code1).
+
+%%%%%%%%%%%%%%%%%%%% Utility Functions %%%%%%%%%%%%%%%%%%%%
+url(Db) ->
+    Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
+    Port = mochiweb_socket_server:get(chttpd, port),
+    lists:concat(["http://", Addr, ":", Port, "/", ?b2l(Db)]).
+
+url(Db, Path) ->
+    url(Db) ++ "/" ++ Path.
+
+url(Db, Path, Parameters) ->
+    url(Db) ++ "/" ++ Path ++ "?" ++ Parameters.
+
+create_db(Db) ->
+    case req(put, url(Db)) of
+        {201, #{}} -> ok;
+        Error -> error({failed_to_create_test_db, Db, Error})
+    end.
+
+create_docs(Db) ->
+    case req(post, url(Db) ++ "/_bulk_docs", ?DOCS) of
+        {201, _} -> ok;
+        Error -> error({failed_to_create_docs, Db, Error})
+    end.
+
+create_ddoc(Db) ->
+    case req(post, url(Db), ?DDOC) of
+        {201, _} -> ok;
+        Error -> error({failed_to_create_ddocs, Db, Error})
+    end.
+
+req(Method, Url) ->
+    Headers = [?CONTENT_JSON, ?AUTH],
+    {ok, Code, _, Res} = test_request:request(Method, Url, Headers),
+    {Code, jiffy:decode(Res, [return_maps])}.
+
+req(Method, Url, #{} = Body) ->
+    req(Method, Url, jiffy:encode(Body));
+req(Method, Url, Body) ->
+    Headers = [?CONTENT_JSON, ?AUTH],
+    {ok, Code, _, Res} = test_request:request(Method, Url, Headers, Body),
+    {Code, jiffy:decode(Res, [return_maps])}.
diff --git a/src/couch_mrview/src/couch_mrview_http.erl b/src/couch_mrview/src/couch_mrview_http.erl
index 9d35e873a..522884eda 100644
--- a/src/couch_mrview/src/couch_mrview_http.erl
+++ b/src/couch_mrview/src/couch_mrview_http.erl
@@ -486,7 +486,12 @@ parse_params(Props, Keys, #mrargs{} = Args0, Options) ->
                 Args0;
             _ ->
                 % group_level set to undefined to detect if explicitly set by user
-                Args0#mrargs{keys = Keys, group = undefined, group_level = undefined}
+                case Keys of
+                    [_] ->
+                        Args0#mrargs{group = undefined, group_level = undefined};
+                    _ ->
+                        Args0#mrargs{keys = Keys, group = undefined, group_level = undefined}
+                end
         end,
     lists:foldl(
         fun({K, V}, Acc) ->
@@ -513,8 +518,24 @@ parse_body_and_query(Req, Keys) ->
 
 parse_body_and_query(Req, {Props}, Keys) ->
     Args = #mrargs{keys = Keys, group = undefined, group_level = undefined},
-    BodyArgs = parse_params(Props, Keys, Args, [decoded]),
-    parse_params(chttpd:qs(Req), Keys, BodyArgs, [keep_group_level]).
+    BodyArgs0 = parse_params(Props, Keys, Args, [decoded]),
+    BodyArgs1 =
+        case is_view(Req) of
+            true -> treat_single_keys_as_key(BodyArgs0);
+            false -> BodyArgs0
+        end,
+    parse_params(chttpd:qs(Req), Keys, BodyArgs1, [keep_group_level]).
+
+is_view(#httpd{path_parts = [_, _, _, <<"_view">> | _]}) ->
+    true;
+is_view(#httpd{}) ->
+    false.
+
+treat_single_keys_as_key(#mrargs{keys = Keys} = BodyArgs) ->
+    case Keys of
+        [_] -> BodyArgs#mrargs{keys = undefined};
+        _ -> BodyArgs
+    end.
 
 parse_param(Key, Val, Args, IsDecoded) when is_binary(Key) ->
     parse_param(binary_to_list(Key), Val, Args, IsDecoded);
@@ -530,9 +551,15 @@ parse_param(Key, Val, Args, IsDecoded) ->
             JsonKey = ?JSON_DECODE(Val),
             Args#mrargs{start_key = JsonKey, end_key = JsonKey};
         "keys" when IsDecoded ->
-            Args#mrargs{keys = Val};
+            case Val of
+                [SingleKey] -> Args#mrargs{start_key = SingleKey, end_key = SingleKey};
+                _ -> Args#mrargs{keys = Val}
+            end;
         "keys" ->
-            Args#mrargs{keys = ?JSON_DECODE(Val)};
+            case ?JSON_DECODE(Val) of
+                [SingleKey] -> Args#mrargs{start_key = SingleKey, end_key = SingleKey};
+                Keys -> Args#mrargs{keys = Keys}
+            end;
         "startkey" when IsDecoded ->
             Args#mrargs{start_key = Val};
         "start_key" when IsDecoded ->
diff --git a/src/couch_mrview/src/couch_mrview_util.erl b/src/couch_mrview/src/couch_mrview_util.erl
index e1e75f34f..1f39959af 100644
--- a/src/couch_mrview/src/couch_mrview_util.erl
+++ b/src/couch_mrview/src/couch_mrview_util.erl
@@ -564,7 +564,7 @@ validate_args(Args) ->
         {red, exact, _} ->
             ok;
         {red, _, KeyList} when is_list(KeyList) ->
-            Msg = <<"Multi-key fetchs for reduce views must use `group=true`">>,
+            Msg = <<"Multi-key fetches for reduce views must use `group=true`">>,
             mrverror(Msg);
         _ ->
             ok
@@ -581,13 +581,12 @@ validate_args(Args) ->
             ok;
         {[], _, _} ->
             ok;
+        {[Key], Key, Key} ->
+            ok;
         {[_ | _], undefined, undefined} ->
             ok;
         _ ->
-            mrverror(<<
-                "`keys` is incompatible with `key`"
-                ", `start_key` and `end_key`"
-            >>)
+            mrverror(<<"`keys` is incompatible with `key`, `start_key` and `end_key`">>)
     end,
 
     case Args#mrargs.start_key_docid of
diff --git a/src/couch_mrview/test/eunit/couch_mrview_all_docs_tests.erl b/src/couch_mrview/test/eunit/couch_mrview_all_docs_tests.erl
index 1a81d4f0a..5b9db435b 100644
--- a/src/couch_mrview/test/eunit/couch_mrview_all_docs_tests.erl
+++ b/src/couch_mrview/test/eunit/couch_mrview_all_docs_tests.erl
@@ -40,6 +40,8 @@ all_docs_test_() ->
                 [
                     fun should_query/1,
                     fun should_query_with_range/1,
+                    fun should_query_with_range_and_same_keys/1,
+                    fun raise_error_query_with_range_and_different_keys/1,
                     fun should_query_with_range_rev/1,
                     fun should_query_with_limit_and_skip/1,
                     fun should_query_with_include_docs/1,
@@ -79,6 +81,20 @@ should_query_with_range(Db) ->
         ]},
     ?_assertEqual(Expect, Result).
 
+should_query_with_range_and_same_keys(Db) ->
+    Result = run_query(Db, [{keys, [<<"3">>]}, {start_key, <<"3">>}, {end_key, <<"3">>}]),
+    Expect =
+        {ok, [
+            {meta, [{total, 11}, {offset, 0}]},
+            mk_row(<<"3">>, <<"1-7fbf84d56f8017880974402d60f5acd6">>)
+        ]},
+    ?_assertEqual(Expect, Result).
+
+raise_error_query_with_range_and_different_keys(Db) ->
+    Error = {query_parse_error, <<"`keys` is incompatible with `key`, `start_key` and `end_key`">>},
+    ?_assertThrow(Error, run_query(Db, [{keys, [<<"1">>]}, {start_key, <<"5">>}])),
+    ?_assertThrow(Error, run_query(Db, [{keys, [<<"5">>]}, {start_key, <<"5">>}])).
+
 should_query_with_range_rev(Db) ->
     Result = run_query(Db, [
         {direction, rev},
diff --git a/src/couch_mrview/test/eunit/couch_mrview_http_tests.erl b/src/couch_mrview/test/eunit/couch_mrview_http_tests.erl
index bfa4965a4..134e27fc0 100644
--- a/src/couch_mrview/test/eunit/couch_mrview_http_tests.erl
+++ b/src/couch_mrview/test/eunit/couch_mrview_http_tests.erl
@@ -33,5 +33,34 @@ mrview_http_test_() ->
                 undefined,
                 #mrargs{}
             )
+        ),
+
+        ?_assertEqual(
+            #mrargs{start_key = 1, end_key = 1, group_level = undefined, group = undefined},
+            couch_mrview_http:parse_params(
+                [{"key", "1"}],
+                undefined,
+                #mrargs{}
+            )
+        ),
+
+        ?_assertEqual(
+            #mrargs{start_key = 1, end_key = 1, group_level = undefined},
+            couch_mrview_http:parse_params(
+                [{"keys", "[1]"}],
+                undefined,
+                #mrargs{}
+            )
+        ),
+
+        ?_assertEqual(
+            #mrargs{
+                keys = [1, 2], start_key = undefined, end_key = undefined, group_level = undefined
+            },
+            couch_mrview_http:parse_params(
+                [{"keys", "[1, 2]"}],
+                undefined,
+                #mrargs{}
+            )
         )
     ].
diff --git a/src/docs/src/api/ddoc/views.rst b/src/docs/src/api/ddoc/views.rst
index 6205c6b86..57bdf95f5 100644
--- a/src/docs/src/api/ddoc/views.rst
+++ b/src/docs/src/api/ddoc/views.rst
@@ -151,6 +151,7 @@
     parameters
 .. versionchanged:: 2.0.0 added ``sorted`` parameter
 .. versionchanged:: 2.1.0 added ``stable`` and ``update`` parameters
+.. versionchanged:: 3.3.1 treat single-element ``keys`` as ``key``
 
 .. warning::
     Using the ``attachments`` parameter to include attachments in view results
@@ -250,7 +251,7 @@ View indexes are updated incrementally in the following situations:
 - A document in the database has been updated.
 
 View indexes are rebuilt entirely when the view definition changes. To achieve
-this, a 'fingerprint' of the view definition is created when the design
+this, a ``fingerprint`` of the view definition is created when the design
 document is updated. If the fingerprint changes, then the view indexes are
 entirely rebuilt. This ensures that changes to the view definitions are
 reflected in the view indexes.
@@ -592,32 +593,33 @@ Sorting order and startkey/endkey
 ---------------------------------
 
 The sorting direction is applied before the filtering applied using the
-``startkey`` and ``endkey`` query arguments. For example the following query:
+``startkey`` and ``endkey`` query arguments. For example the following query
+will operate correctly when listing all the matching entries between
+``carrots`` and ``egg``:
 
 .. code-block:: http
 
-    GET http://couchdb:5984/recipes/_design/recipes/_view/by_ingredient?startkey=%22carrots%22&endkey=%22egg%22 HTTP/1.1
+    GET http://couchdb:5984/recipes/_design/recipes/_view/by_ingredient?startkey="carrots"&endkey="egg" HTTP/1.1
     Accept: application/json
 
-will operate correctly when listing all the matching entries between
-``carrots`` and ``egg``. If the order of output is reversed with the
-``descending`` query argument, the view request will return no entries:
+If the order of output is reversed with the ``descending`` query argument,
+the view request will get a 400 Bad Request response:
 
 .. code-block:: http
 
-    GET /recipes/_design/recipes/_view/by_ingredient?descending=true&startkey=%22carrots%22&endkey=%22egg%22 HTTP/1.1
+    GET /recipes/_design/recipes/_view/by_ingredient?descending=true&startkey="carrots"&endkey="egg" HTTP/1.1
     Accept: application/json
     Host: localhost:5984
 
     {
-        "total_rows" : 26453,
-        "rows" : [],
-        "offset" : 21882
+        "error": "query_parse_error",
+        "reason": "No rows can match your key range, reverse your start_key and end_key or set descending=false",
+        "ref": 3986383855
     }
 
-The results will be empty because the entries in the view are reversed before
-the key filter is applied, and therefore the ``endkey`` of “egg” will be seen
-before the ``startkey`` of “carrots”, resulting in an empty list.
+The result will be an error because the entries in the view are reversed before
+the key filter is applied, so the ``endkey`` of "egg" will be seen before the
+``startkey`` of "carrots".
 
 Instead, you should reverse the values supplied to the ``startkey`` and
 ``endkey`` parameters to match the descending sorting applied to the keys.
@@ -625,10 +627,65 @@ Changing the previous example to:
 
 .. code-block:: http
 
-    GET /recipes/_design/recipes/_view/by_ingredient?descending=true&startkey=%22egg%22&endkey=%22carrots%22 HTTP/1.1
+    GET /recipes/_design/recipes/_view/by_ingredient?descending=true&startkey="egg"&endkey="carrots" HTTP/1.1
     Accept: application/json
     Host: localhost:5984
 
+Using key, keys, start_key and end_key
+---------------------------------------
+
+``key``: Behaves like setting ``start_key=$key&end_key=$key``.
+
+``keys``: there are some differences between single-element ``keys`` and
+multi-element ``keys``. For single-element ``keys``, treat it as a ``key``.
+
+.. code-block:: bash
+
+    $ curl -X POST http://adm:pass@127.0.0.1:5984/db/_bulk_docs \
+           -H 'Content-Type: application/json' \
+           -d '{"docs":[{"_id":"a","key":"a","value":1},{"_id":"b","key":"b","value":2},{"_id":"c","key":"c","value":3}]}'
+    $ curl -X POST http://adm:pass@127.0.0.1:5984/db \
+           -H 'Content-Type: application/json' \
+           -d '{"_id":"_design/ddoc","views":{"reduce":{"map":"function(doc) { emit(doc.key, doc.value) }","reduce":"_sum"}}}'
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?key="a"'
+    {"rows":[{"key":null,"value":1}]}
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?keys="[\"a\"]"'
+    {"rows":[{"key":null,"value":1}]}
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?keys=\["a","b"\]'
+    {"error":"query_parse_error","reason":"Multi-key fetches for reduce views must use `group=true`"}
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?keys=\["a","c"\]&group=true'
+    {"rows":[{"key":"a","value":1},{"key":"c","value":3}]}
+
+``keys`` is incompatible with ``key``, ``start_key`` and ``end_key``,
+but it's possible to use ``key`` with ``start_key`` and ``end_key``.
+Different orders of query parameters may result in different responses.
+Precedence is the order in which query parameters are specified. Usually,
+the last argument wins.
+
+.. code-block:: bash
+
+    # start_key=a and end_key=b
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?key="a"&endkey="b"'
+    {"rows":[{"key":null,"value":3}]}
+
+    # start_key=a and end_key=a
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?endkey="b"&key="a"'
+    {"rows":[{"key":null,"value":1}]}
+
+    # start_key=a and end_key=a
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?endkey="b"&keys=\["a"\]'
+    {"rows":[{"key":null,"value":1}]}
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?endkey="b"&keys=\["a","b"\]'
+    {"error":"query_parse_error","reason":"Multi-key fetches for reduce views must use `group=true`"}
+
+    $ curl http://adm:pass@127.0.0.1:5984/db/_design/ddoc/_view/reduce'?endkey="b"&keys=\["a","b"\]&group=true'
+    {"error":"query_parse_error","reason":"`keys` is incompatible with `key`, `start_key` and `end_key`"}
+
 .. _api/ddoc/view/sorting/raw:
 
 Raw collation