string(JSON): Add PARTIAL_EQUAL for subset style JSON comparisons

Fixes: #27641
This commit is contained in:
Taylor Sasser
2026-02-23 16:00:46 -05:00
committed by Brad King
parent 95a07e2fc6
commit b14da400de
5 changed files with 422 additions and 3 deletions

View File

@@ -59,6 +59,8 @@ Synopsis
<member|index> [<member|index> ...] <value>)
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
`EQUAL <JSON-EQUAL_>`__ <json-string1> <json-string2>)
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
`PARTIAL_EQUAL <JSON-EQUAL_>`__ <json-string1> <json-string2>)
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
`STRING_ENCODE <STRING-ENCODE_>`__ <string>)
@@ -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 <out-var> [ERROR_VARIABLE <error-var>]
PARTIAL_EQUAL <json-pattern> <json-actual>)
:target: JSON-PARTIAL_EQUAL
.. versionadded:: 4.4
Compare two JSON values using *partial* equality. The first argument
(``<json-pattern>``) is treated as a pattern to match against the second
argument (``<json-actual>``), which is treated as the complete JSON value.
The contents of ``<json-pattern>`` and ``<json-actual>`` must be valid JSON.
The ``<out-var>`` will be set to a true value if ``<json-pattern>`` is
contained within ``<json-actual>`` 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 ``<json-pattern>`` exists in
``<json-actual>`` and the corresponding values also match recursively.
Extra members in ``<json-actual>`` are ignored.
* **Arrays** match if every element in ``<json-pattern>`` matches a
distinct element in ``<json-actual>`` recursively. Once an element in
``<json-actual>`` successfully matches an element in ``<json-pattern>``,
it is consumed and cannot be reused to match any subsequent elements.
Array element order is not significant. Extra elements in
``<json-actual>`` 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 <out-var> [ERROR_VARIABLE <error-var>]
STRING_ENCODE <string>)

View File

@@ -0,0 +1,5 @@
json-partial-equals
-------------------
* The :command:`string(JSON ... PARTIAL_EQUAL) <string(JSON-PARTIAL_EQUAL)>`
command was added to compare two JSON values using *partial* equality.

View File

@@ -5,6 +5,7 @@
#include "cmStringCommand.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <exception>
@@ -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<std::string> 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<bool> 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<std::string> const& arguments,
@@ -954,11 +1005,12 @@ bool HandleJSONCommand(std::vector<std::string> 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<std::string> 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));
}
}

View File

@@ -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()

View File

@@ -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\)