From b14da400de419a71b249bc99d5e3cb92be2dd165 Mon Sep 17 00:00:00 2001 From: Taylor Sasser Date: Mon, 23 Feb 2026 16:00:46 -0500 Subject: [PATCH] string(JSON): Add PARTIAL_EQUAL for subset style JSON comparisons Fixes: #27641 --- Help/command/string.rst | 38 +++ Help/release/dev/json-partial-equals.rst | 5 + Source/cmStringCommand.cxx | 62 +++- Tests/RunCMake/string/JSON.cmake | 317 ++++++++++++++++++ .../RunCMake/string/JSONWrongMode-stderr.txt | 3 +- 5 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 Help/release/dev/json-partial-equals.rst diff --git a/Help/command/string.rst b/Help/command/string.rst index b7ed69a96b..6564ec5ca4 100644 --- a/Help/command/string.rst +++ b/Help/command/string.rst @@ -59,6 +59,8 @@ Synopsis [ ...] ) string(JSON [ERROR_VARIABLE ] `EQUAL `__ ) + string(JSON [ERROR_VARIABLE ] + `PARTIAL_EQUAL `__ ) string(JSON [ERROR_VARIABLE ] `STRING_ENCODE `__ ) @@ -637,6 +639,42 @@ string is passed as a single argument even if it contains semicolons. will be set to a true value if the JSON objects are considered equal, or a false value otherwise. +.. signature:: + string(JSON [ERROR_VARIABLE ] + PARTIAL_EQUAL ) + :target: JSON-PARTIAL_EQUAL + + .. versionadded:: 4.4 + + Compare two JSON values using *partial* equality. The first argument + (````) is treated as a pattern to match against the second + argument (````), which is treated as the complete JSON value. + The contents of ```` and ```` must be valid JSON. + + The ```` will be set to a true value if ```` is + contained within ```` according to the rules below, or a false + value otherwise. + + Partial equality is not symmetric. In general, + ``PARTIAL_EQUAL(A, B)`` and ``PARTIAL_EQUAL(B, A)`` may produce different + results. + + Matching rules: + + * **Scalars** (null, number, string, boolean) must match exactly. + * **Objects** match if every member in ```` exists in + ```` and the corresponding values also match recursively. + Extra members in ```` are ignored. + * **Arrays** match if every element in ```` matches a + distinct element in ```` recursively. Once an element in + ```` successfully matches an element in ````, + it is consumed and cannot be reused to match any subsequent elements. + Array element order is not significant. Extra elements in + ```` are ignored. + + Type differences do not match (for example, an object does not match an + array, and a string does not match a number). + .. signature:: string(JSON [ERROR_VARIABLE ] STRING_ENCODE ) diff --git a/Help/release/dev/json-partial-equals.rst b/Help/release/dev/json-partial-equals.rst new file mode 100644 index 0000000000..2149421822 --- /dev/null +++ b/Help/release/dev/json-partial-equals.rst @@ -0,0 +1,5 @@ +json-partial-equals +------------------- + +* The :command:`string(JSON ... PARTIAL_EQUAL) ` + command was added to compare two JSON values using *partial* equality. diff --git a/Source/cmStringCommand.cxx b/Source/cmStringCommand.cxx index 1a888c15fa..f67b074fe2 100644 --- a/Source/cmStringCommand.cxx +++ b/Source/cmStringCommand.cxx @@ -5,6 +5,7 @@ #include "cmStringCommand.h" +#include #include #include #include @@ -928,6 +929,56 @@ std::string WriteJson(Json::Value const& value) return Json::writeString(writer, value); } +bool JsonPartialMatch(Json::Value const& pattern, Json::Value const& actual) +{ + if (pattern.type() != actual.type()) { + return false; + } + switch (pattern.type()) { + case Json::nullValue: + case Json::intValue: + case Json::uintValue: + case Json::realValue: + case Json::stringValue: + case Json::booleanValue: + return pattern == actual; + + case Json::objectValue: { + std::vector const keys = pattern.getMemberNames(); + return std::all_of(keys.begin(), keys.end(), + [&](std::string const& key) { + return actual.isMember(key) && + JsonPartialMatch(pattern[key], actual[key]); + }); + } + + case Json::arrayValue: { + if (actual.size() < pattern.size()) { + return false; + } + std::vector matched(actual.size(), false); + for (Json::Value const& p : pattern) { + bool found = false; + for (Json::ArrayIndex j = 0; j < actual.size(); ++j) { + if (matched[j]) { + continue; + } + if (JsonPartialMatch(p, actual[j])) { + matched[j] = true; + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + } + return false; +} + #endif bool HandleJSONCommand(std::vector const& arguments, @@ -954,11 +1005,12 @@ bool HandleJSONCommand(std::vector const& arguments, auto const& mode = args.PopFront("missing mode argument"_s); if (mode != "GET"_s && mode != "GET_RAW"_s && mode != "TYPE"_s && mode != "MEMBER"_s && mode != "LENGTH"_s && mode != "REMOVE"_s && - mode != "SET"_s && mode != "EQUAL"_s && mode != "STRING_ENCODE"_s) { + mode != "SET"_s && mode != "EQUAL"_s && mode != "STRING_ENCODE"_s && + mode != "PARTIAL_EQUAL"_s) { throw json_error(cmStrCat( "got an invalid mode '"_s, mode, "', expected one of GET, GET_RAW, TYPE, MEMBER, LENGTH, REMOVE, SET, " - " EQUAL, STRING_ENCODE"_s)); + " EQUAL, PARTIAL_EQUAL, STRING_ENCODE"_s)); } auto const& jsonstr = args.PopFront("missing json string argument"_s); @@ -1067,6 +1119,12 @@ bool HandleJSONCommand(std::vector const& arguments, args.PopFront("missing second json string argument"_s); Json::Value json2 = ReadJson(jsonstr2); makefile.AddDefinitionBool(*outputVariable, json == json2); + } else if (mode == "PARTIAL_EQUAL"_s) { + auto const& jsonstr2 = + args.PopFront("missing second json string argument"_s); + Json::Value json2 = ReadJson(jsonstr2); + makefile.AddDefinitionBool(*outputVariable, + JsonPartialMatch(json, json2)); } } diff --git a/Tests/RunCMake/string/JSON.cmake b/Tests/RunCMake/string/JSON.cmake index 1a988e193f..5cbc9334a0 100644 --- a/Tests/RunCMake/string/JSON.cmake +++ b/Tests/RunCMake/string/JSON.cmake @@ -393,3 +393,320 @@ string(JSON result STRING_ENCODE {}) assert_strequal("${result}" "\"{}\"") string(JSON result STRING_ENCODE []) assert_strequal("${result}" "\"[]\"") + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "foo":"bar" +} +]=] +[=[ +{ + "foo": "bar", + "extra": 1 +} +]=]) + +if(NOT result) + message(SEND_ERROR "Expected ON got ${result}") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "foo":"bar" +} +]=] +[=[ +{ + "foo1": "bar" +} +]=]) +if(result) + message(SEND_ERROR "EXPECTED OFF got ${result}") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "types" : { + "number" : 5 + } +} +]=] +[=[ +{ + "foo" : "bar", + "array" : [5, "val", {"some": "other"}, null], + "types" : { + "null" : null, + "number" : 5, + "string" : "foo", + "boolean" : false, + "array" : [1,2,3], + "object" : {} + }, + "values" : { + "null" : null, + "number" : 5, + "string" : "foo", + "false" : false, + "true" : true + }, + "object": { + "foo": "bar" + }, + "special" : { + "foo;bar" : "value1", + ";" : "value2", + "semicolon" : ";", + "list" : ["one", "two;three", "four"], + "quote" : "\"", + "\"" : "quote", + "backslash" : "\\", + "\\" : "backslash", + "slash" : "\/", + "\/" : "slash", + "newline" : "\n", + "\n" : "newline", + "return" : "\r", + "\r" : "return", + "tab" : "\t", + "\t" : "tab", + "backspace" : "\b", + "\b" : "backspace", + "formfeed" : "\f", + "\f" : "formfeed" + } +} +]=]) +if(NOT result) + message(SEND_ERROR "EXPECTED ON got ${result} for nested subset") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "types" : { + "number" : 6 + } +} +]=] +[=[ +{ + "foo" : "bar", + "array" : [5, "val", {"some": "other"}, null], + "types" : { + "null" : null, + "number" : 5, + "string" : "foo", + "boolean" : false, + "array" : [1,2,3], + "object" : {} + }, + "values" : { + "null" : null, + "number" : 5, + "string" : "foo", + "false" : false, + "true" : true + }, + "object": { + "foo": "bar" + }, + "special" : { + "foo;bar" : "value1", + ";" : "value2", + "semicolon" : ";", + "list" : ["one", "two;three", "four"], + "quote" : "\"", + "\"" : "quote", + "backslash" : "\\", + "\\" : "backslash", + "slash" : "\/", + "\/" : "slash", + "newline" : "\n", + "\n" : "newline", + "return" : "\r", + "\r" : "return", + "tab" : "\t", + "\t" : "tab", + "backspace" : "\b", + "\b" : "backspace", + "formfeed" : "\f", + "\f" : "formfeed" + } +} +]=]) +if(result) + message(SEND_ERROR "EXPECTED OFF got ${result} for nested mismatch") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +[ + 2, + 1 +] +]=] +[=[ +[ + 1, + 2, + 3 +] +]=]) +if(NOT result) + message(SEND_ERROR "Expected ON got ${result} for unordered array subset") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +[ + 2, + 4 +] +]=] +[=[ +[ + 1, + 2, + 3 +] +]=]) +if(result) + message(SEND_ERROR "Expected OFF got ${result} for missing array element") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +[ + 1, + 2, + 3 +] +]=] +[=[ +[ + 1, + 2 +] +]=]) +if(result) + message(SEND_ERROR "Expected OFF got ${result} for pattern larger than actual") +endif() + +string(JSON result PARTIAL_EQUAL + [=[ +[ + 1, + 1 +] +]=] +[=[ +[ + 1, + 2, + 1 +] +]=]) +if(NOT result) + message(SEND_ERROR "EXPECTED ON got ${result} for duplicate match") +endif() + +string(JSON result PARTIAL_EQUAL + [=[ +[ + { + "id":2 + }, + { + "id":1 + } +] +]=] +[=[ +[ + { + "id":1, + "name":"A" + }, + { + "id":2, + "name":"B" + }, + { + "id":3, + "name":"C" + } +] +]=]) +if(NOT result) + message(SEND_ERROR "EXPECTED ON got ${result} for array of object subset") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +[ + { + "id":4 + } +] +]=] +[=[ +[ + { + "id":1 + }, + { + "id":2 + } +] +]=]) +if(result) + message(SEND_ERROR "Expected OFF got ${result} for array of object mismatch") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "tags": [ + "b", + "a" + ] +} +]=] +[=[ +{ + "tags": [ + "a", + "b", + "c" + ], + "other": 123 +} +]=]) +if(NOT result) + message(SEND_ERROR "EXPECTED ON got ${result} for object+array partial match") +endif() + +string(JSON result PARTIAL_EQUAL +[=[ +{ + "tags": [ + "a", + "a" + ] +} +]=] +[=[ +{ + "tags": [ + "a", + "b" + ] +} +]=]) +if(result) + message(SEND_ERROR "Expected OFF got ${result} for duplicate array mismatch in object") +endif() diff --git a/Tests/RunCMake/string/JSONWrongMode-stderr.txt b/Tests/RunCMake/string/JSONWrongMode-stderr.txt index b37a084f96..41ecc12bfc 100644 --- a/Tests/RunCMake/string/JSONWrongMode-stderr.txt +++ b/Tests/RunCMake/string/JSONWrongMode-stderr.txt @@ -1,5 +1,6 @@ CMake Error at JSONWrongMode\.cmake:1 \(string\): string sub-command JSON got an invalid mode 'FOO', expected one of GET, - GET_RAW, TYPE, MEMBER, LENGTH, REMOVE, SET, EQUAL, STRING_ENCODE\. + GET_RAW, TYPE, MEMBER, LENGTH, REMOVE, SET, EQUAL, PARTIAL_EQUAL, + STRING_ENCODE\. Call Stack \(most recent call first\): CMakeLists\.txt:[0-9]+ \(include\)