From 5610e7688d279a9dd4d5e5ad24c0cb48a277b71c Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 9 Jan 2026 12:47:54 +0000 Subject: [PATCH 1/4] fix: Align vdu_rejects counter with actual VDU behaviour This counter is incremented whenever a VDU returns a value other than `1`, whereas `ok` and `true` are also treated as acceptable success values. This fixes the counter to only increment on actual failure responses. --- src/couch/src/couch_query_servers.erl | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/couch/src/couch_query_servers.erl b/src/couch/src/couch_query_servers.erl index 1652fc09a2a..7ab662f850c 100644 --- a/src/couch/src/couch_query_servers.erl +++ b/src/couch/src/couch_query_servers.erl @@ -477,18 +477,18 @@ builtin_cmp_last(A, B) -> validate_doc_update(Db, DDoc, EditDoc, DiskDoc, Ctx, SecObj) -> JsonEditDoc = couch_doc:to_json_obj(EditDoc, [revs]), JsonDiskDoc = json_doc(DiskDoc), - Resp = ddoc_prompt( - Db, - DDoc, - [<<"validate_doc_update">>], - [JsonEditDoc, JsonDiskDoc, Ctx, SecObj] - ), - if - Resp == 1 -> ok; - true -> couch_stats:increment_counter([couchdb, query_server, vdu_rejects], 1) - end, + Args = [JsonEditDoc, JsonDiskDoc, Ctx, SecObj], + + Resp = + case ddoc_prompt(Db, DDoc, [<<"validate_doc_update">>], Args) of + Code when Code =:= 1; Code =:= ok; Code =:= true -> + ok; + Other -> + couch_stats:increment_counter([couchdb, query_server, vdu_rejects], 1), + Other + end, case Resp of - RespCode when RespCode =:= 1; RespCode =:= ok; RespCode =:= true -> + ok -> ok; {[{<<"forbidden">>, Message}]} -> throw({forbidden, Message}); From 3cba0ae8199aa6503df29829a6233ce3ec7ee472 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Thu, 8 Jan 2026 15:19:47 +0000 Subject: [PATCH 2/4] chore: Add some basic testing for the JS-based VDU interface --- test/elixir/test/config/suite.elixir | 6 ++ test/elixir/test/validate_doc_update_test.exs | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 test/elixir/test/validate_doc_update_test.exs diff --git a/test/elixir/test/config/suite.elixir b/test/elixir/test/config/suite.elixir index d8c25437306..6acd4fab781 100644 --- a/test/elixir/test/config/suite.elixir +++ b/test/elixir/test/config/suite.elixir @@ -521,6 +521,12 @@ "serial execution is not spuriously counted as loop on test_rewrite_suite_db", "serial execution is not spuriously counted as loop on test_rewrite_suite_db%2Fwith_slashes" ], + "ValidateDocUpdateTest": [ + "JavaScript VDU accepts a valid document", + "JavaScript VDU rejects an invalid document", + "JavaScript VDU accepts a valid change", + "JavaScript VDU rejects an invalid change", + ], "SecurityValidationTest": [ "Author presence and user security", "Author presence and user security when replicated", diff --git a/test/elixir/test/validate_doc_update_test.exs b/test/elixir/test/validate_doc_update_test.exs new file mode 100644 index 00000000000..5d15db10165 --- /dev/null +++ b/test/elixir/test/validate_doc_update_test.exs @@ -0,0 +1,79 @@ +defmodule ValidateDocUpdateTest do + use CouchTestCase + + @moduledoc """ + Test validate_doc_update behaviour + """ + + @js_type_check %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc) { + if (!newDoc.type) { + throw {forbidden: 'Documents must have a type field'}; + } + } + """ + } + + @tag :with_db + test "JavaScript VDU accepts a valid document", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_type_check) + + resp = Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + assert resp.status_code == 201 + assert resp.body["ok"] == true + end + + @tag :with_db + test "JavaScript VDU rejects an invalid document", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_type_check) + + resp = Couch.put("/#{db}/doc", body: %{"not" => "valid"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @js_change_check %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc, oldDoc) { + if (oldDoc && newDoc.type !== oldDoc.type) { + throw {forbidden: 'Documents cannot change their type field'}; + } + } + """ + } + + @tag :with_db + test "JavaScript VDU accepts a valid change", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_change_check) + + Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + + doc = Couch.get("/#{db}/doc").body + updated = doc |> Map.merge(%{"type" => "movie", "title" => "Duck Soup"}) + resp = Couch.put("/#{db}/doc", body: updated) + + assert resp.status_code == 201 + end + + @tag :with_db + test "JavaScript VDU rejects an invalid change", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_change_check) + + Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + + doc = Couch.get("/#{db}/doc").body + updated = doc |> Map.put("type", "director") + resp = Couch.put("/#{db}/doc", body: updated) + + assert resp.status_code == 403 + end +end From 166335b0e8761f845ef0227d6fecd3e65783bc92 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 9 Jan 2026 14:05:43 +0000 Subject: [PATCH 3/4] feat: Add the ability for VDUs to be written as Mango selectors --- src/couch_mrview/src/couch_mrview.erl | 2 +- src/mango/src/mango_native_proc.erl | 27 ++++ test/elixir/test/config/suite.elixir | 6 + test/elixir/test/validate_doc_update_test.exs | 133 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/couch_mrview/src/couch_mrview.erl b/src/couch_mrview/src/couch_mrview.erl index bc7b1f8abf3..244f668af03 100644 --- a/src/couch_mrview/src/couch_mrview.erl +++ b/src/couch_mrview/src/couch_mrview.erl @@ -62,7 +62,7 @@ validate_ddoc_fields(DDoc) -> [{<<"rewrites">>, [string, array]}], [{<<"shows">>, object}, {any, [object, string]}], [{<<"updates">>, object}, {any, [object, string]}], - [{<<"validate_doc_update">>, string}], + [{<<"validate_doc_update">>, [string, object]}], [{<<"views">>, object}, {<<"lib">>, object}], [{<<"views">>, object}, {any, object}, {<<"map">>, MapFuncType}], [{<<"views">>, object}, {any, object}, {<<"reduce">>, string}] diff --git a/src/mango/src/mango_native_proc.erl b/src/mango/src/mango_native_proc.erl index 511a987199e..edcecd4b6fb 100644 --- a/src/mango/src/mango_native_proc.erl +++ b/src/mango/src/mango_native_proc.erl @@ -29,6 +29,7 @@ -record(st, { indexes = [], + validators = [], timeout = 5000 }). @@ -94,6 +95,32 @@ handle_call({prompt, [<<"nouveau_index_doc">>, Doc]}, _From, St) -> Else end, {reply, Vals, St}; +handle_call({prompt, [<<"ddoc">>, <<"new">>, DDocId, {DDoc}]}, _From, St) -> + NewSt = + case couch_util:get_value(<<"validate_doc_update">>, DDoc) of + undefined -> + St; + Selector0 -> + Selector = mango_selector:normalize(Selector0), + Validators = couch_util:set_value(DDocId, St#st.validators, Selector), + St#st{validators = Validators} + end, + {reply, true, NewSt}; +handle_call({prompt, [<<"ddoc">>, DDocId, [<<"validate_doc_update">>], Args]}, _From, St) -> + case couch_util:get_value(DDocId, St#st.validators) of + undefined -> + Msg = [<<"validate_doc_update">>, DDocId], + {stop, {invalid_call, Msg}, {invalid_call, Msg}, St}; + Selector -> + [NewDoc, OldDoc, _Ctx, _SecObj] = Args, + Struct = {[{<<"newDoc">>, NewDoc}, {<<"oldDoc">>, OldDoc}]}, + Reply = + case mango_selector:match(Selector, Struct) of + true -> true; + _ -> {[{<<"forbidden">>, <<"document is not valid">>}]} + end, + {reply, Reply, St} + end; handle_call(Msg, _From, St) -> {stop, {invalid_call, Msg}, {invalid_call, Msg}, St}. diff --git a/test/elixir/test/config/suite.elixir b/test/elixir/test/config/suite.elixir index 6acd4fab781..21bfe3dc03b 100644 --- a/test/elixir/test/config/suite.elixir +++ b/test/elixir/test/config/suite.elixir @@ -526,6 +526,12 @@ "JavaScript VDU rejects an invalid document", "JavaScript VDU accepts a valid change", "JavaScript VDU rejects an invalid change", + "Mango VDU accepts a valid document", + "Mango VDU rejects an invalid document", + "updating a Mango VDU updates its effects", + "converting a Mango VDU to JavaScript updates its effects", + "deleting a Mango VDU removes its effects", + "Mango VDU rejects a doc if any existing ddoc fails to match", ], "SecurityValidationTest": [ "Author presence and user security", diff --git a/test/elixir/test/validate_doc_update_test.exs b/test/elixir/test/validate_doc_update_test.exs index 5d15db10165..93ed8f177cf 100644 --- a/test/elixir/test/validate_doc_update_test.exs +++ b/test/elixir/test/validate_doc_update_test.exs @@ -76,4 +76,137 @@ defmodule ValidateDocUpdateTest do assert resp.status_code == 403 end + + @mango_type_check %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{"type" => %{"$exists" => true}} + } + } + + @tag :with_db + test "Mango VDU accepts a valid document", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + assert resp.status_code == 201 + assert resp.body["ok"] == true + end + + @tag :with_db + test "Mango VDU rejects an invalid document", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc", body: %{"no" => "type"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "updating a Mango VDU updates its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{ + "type" => %{"$type" => "string"}, + "year" => %{"$lt" => 2026} + } + } + } + resp = Couch.put("/#{db}/_design/mango-test", body: ddoc, query: %{rev: resp.body["rev"]}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"type" => "movie", "year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"type" => 42, "year" => 1994}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + + resp = Couch.put("/#{db}/doc3", body: %{"type" => "movie", "year" => 2094}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "converting a Mango VDU to JavaScript updates its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc) { + if (typeof newDoc.year !== 'number') { + throw {forbidden: 'Documents must have a valid year field'}; + } + } + """ + } + resp = Couch.put("/#{db}/_design/mango-test", body: ddoc, query: %{rev: resp.body["rev"]}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"year" => "1994"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "deleting a Mango VDU removes its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.delete("/#{db}/_design/mango-test", query: %{rev: resp.body["rev"]}) + assert resp.status_code == 200 + + resp = Couch.put("/#{db}/doc", body: %{"no" => "type"}) + assert resp.status_code == 201 + end + + @tag :with_db + test "Mango VDU rejects a doc if any existing ddoc fails to match", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{"year" => %{"$lt" => 2026}} + } + } + resp = Couch.put("/#{db}/_design/mango-test-2", body: ddoc) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"type" => "movie", "year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"year" => 1994}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + + resp = Couch.put("/#{db}/doc3", body: %{"type" => "movie", "year" => 2094}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end end From ae57d855d845b4da481979e620981fbfba59dc79 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Wed, 11 Mar 2026 13:35:04 +0000 Subject: [PATCH 4/4] docs: Documentation for Mango-selector-based VDUs --- src/docs/src/api/ddoc/common.rst | 5 ++- src/docs/src/ddocs/ddocs.rst | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/docs/src/api/ddoc/common.rst b/src/docs/src/api/ddoc/common.rst index 584fb8ecb43..961af135b06 100644 --- a/src/docs/src/api/ddoc/common.rst +++ b/src/docs/src/api/ddoc/common.rst @@ -54,8 +54,9 @@ * **rewrites** (*array* or *string*): Rewrite rules definition. *Deprecated.* * **shows** (*object*): :ref:`Show functions ` definition. *Deprecated.* * **updates** (*object*): :ref:`Update functions ` definition - * **validate_doc_update** (*string*): :ref:`Validate document update - ` function source + * **validate_doc_update** (*string* or *object*): :ref:`Validate document + update ` JavaScript function source, or :ref:`Mango selector + ` * **views** (*object*): :ref:`View functions ` definition. * **autoupdate** (*boolean*): Indicates whether to automatically build indexes defined in this design document. Default is ``true``. diff --git a/src/docs/src/ddocs/ddocs.rst b/src/docs/src/ddocs/ddocs.rst index fcb741e5fa9..501501857ce 100644 --- a/src/docs/src/ddocs/ddocs.rst +++ b/src/docs/src/ddocs/ddocs.rst @@ -937,3 +937,78 @@ modified by a user with the ``_admin`` role: CouchDB Guide: - `Validation Functions `_ + +Validation using Mango selectors +-------------------------------- + +The ``validate_doc_update`` field may be written as a :ref:`Mango selector +`, instead of as a JavaScript function. This provides greater +performance since documents do not need to be sent to an external process for +validation, but is more restrictive in terms of what kinds of validation rules +can be expressed. Mango selectors can express declarative rules about the +strucure of the existing document stored on disk, and the new version of the +document; the document must match the given selector in order for the update to +be accepted. + +To use Mango selectors for validation, the design document must have the +``language`` field set to ``query``. The selector is applied to a JSON structure +containing the following fields: + +* ``newDoc``: New version of document that will be stored. +* ``oldDoc``: Previous version of document that is already stored. + +For example, to check that all docs contain a ``title`` which is a string, and a +``year`` which is a number: + +.. code-block:: json + + { + "language": "query", + + "validate_doc_update": { + "newDoc": { + "title": { "$type": "string" }, + "year": { "$type": "number" } + } + } + } + +All the features of Mango selectors are supported here, so any condition that +can be expressed as a selector can be implemented in this way. Operators like +``$lt`` and ``$gt`` can be used to restrict values to a given range, +``$allMatch`` can be used to check all the items in an array match some schema, +and it is even possible to implement conditional checks using logical +combinators. + +For example, say we want documents with ``"type": "movie"`` to have a ``title`` +and ``year`` as above, and documents with ``"type": "actor"`` to have a ``name`` +and a non-empty list of strings under ``movies``. This can be achieved using +this design document: + +.. code-block:: json + + { + "language": "query", + + "validate_doc_update": { + "newDoc": { + "type": { "$in": ["movie", "actor"] }, + "$or": [ + { + "type": "movie", + "title": { "$type": "string" }, + "year": { "$type": "number" } + }, + { + "type": "actor", + "name": { "$type": "string" }, + "movies": { + "$type": "array", + "$not": { "$size": 0 }, + "$allMatch": { "$type": "string" } + } + } + } + } + } + }