From 2101f579309f9ba92c212a69fb0882f050be2860 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Fri, 3 Jul 2026 16:47:34 -0300 Subject: [PATCH 1/2] [WIP] Support JSON-LD annotations over instances Signed-off-by: Juan Cruz Viotti --- config.cmake.in | 2 +- contrib/CMakeLists.txt | 17 + contrib/jsonld.cc | 78 + src/output/CMakeLists.txt | 7 +- src/output/include/sourcemeta/blaze/output.h | 1 + .../include/sourcemeta/blaze/output_jsonld.h | 97 + src/output/output_jsonld.cc | 224 +++ test/output/CMakeLists.txt | 5 +- test/output/output_jsonld_test.cc | 1786 +++++++++++++++++ 9 files changed, 2213 insertions(+), 4 deletions(-) create mode 100644 contrib/jsonld.cc create mode 100644 src/output/include/sourcemeta/blaze/output_jsonld.h create mode 100644 src/output/output_jsonld.cc create mode 100644 test/output/output_jsonld_test.cc diff --git a/config.cmake.in b/config.cmake.in index b65964553..74ecce022 100644 --- a/config.cmake.in +++ b/config.cmake.in @@ -22,7 +22,7 @@ endif() include(CMakeFindDependencyMacro) find_dependency(Core COMPONENTS unicode punycode idna regex uri uritemplate json - jsonpointer io yaml crypto html email ip dns time css) + jsonpointer jsonld io yaml crypto html email ip dns time css) foreach(component ${BLAZE_COMPONENTS}) if(component STREQUAL "foundation") diff --git a/contrib/CMakeLists.txt b/contrib/CMakeLists.txt index 04c9daab9..59b01718f 100644 --- a/contrib/CMakeLists.txt +++ b/contrib/CMakeLists.txt @@ -44,6 +44,23 @@ if(BLAZE_COMPILER AND BLAZE_EVALUATOR) target_link_libraries(sourcemeta_blaze_contrib_trace PRIVATE sourcemeta::blaze::output) + sourcemeta_executable(NAMESPACE sourcemeta PROJECT blaze NAME contrib_jsonld + FOLDER "Blaze/Contrib" SOURCES jsonld.cc) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::core::io) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::core::json) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::core::jsonpointer) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::blaze::foundation) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::blaze::compiler) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::blaze::evaluator) + target_link_libraries(sourcemeta_blaze_contrib_jsonld + PRIVATE sourcemeta::blaze::output) + if(${CMAKE_SYSTEM_NAME} MATCHES "Linux") sourcemeta_executable(NAMESPACE sourcemeta PROJECT blaze NAME contrib_perf FOLDER "Blaze/Contrib" SOURCES perf.cc) diff --git a/contrib/jsonld.cc b/contrib/jsonld.cc new file mode 100644 index 000000000..a3aa271bc --- /dev/null +++ b/contrib/jsonld.cc @@ -0,0 +1,78 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include // EXIT_SUCCESS, EXIT_FAILURE +#include // std::exception +#include // std::cerr, std::cout +#include // std::unordered_set +#include // std::get, std::holds_alternative + +// A convenience script to evaluate an instance against a JSON Schema whose +// atoms carry JSON-LD annotations, and print the resulting expanded JSON-LD, +// the schema validation errors, or the JSON-LD resolution error. + +static auto run(const char *schema_path, const char *instance_path) -> int; + +auto main(int argc, char *argv[]) -> int { + if (argc != 3) { + std::cerr << "Usage: " << argv[0] << " \n"; + return EXIT_FAILURE; + } + + try { + return run(argv[1], argv[2]); + } catch (const std::exception &error) { + std::cerr << "Error: " << error.what() << "\n"; + return EXIT_FAILURE; + } +} + +static auto run(const char *schema_path, const char *instance_path) -> int { + const auto schema{sourcemeta::core::read_json(schema_path)}; + const auto instance{sourcemeta::core::read_json(instance_path)}; + + sourcemeta::blaze::Tweaks tweaks; + tweaks.annotations = std::unordered_set{ + "x-jsonld-id", "x-jsonld-type"}; + const auto schema_template{sourcemeta::blaze::compile( + schema, sourcemeta::blaze::schema_walker, + sourcemeta::blaze::schema_resolver, + sourcemeta::blaze::default_schema_compiler, + sourcemeta::blaze::Mode::FastValidation, "", "", "", tweaks)}; + + sourcemeta::blaze::Evaluator evaluator; + const auto outcome{ + sourcemeta::blaze::jsonld(evaluator, schema_template, instance)}; + + if (std::holds_alternative(outcome)) { + sourcemeta::core::prettify(std::get(outcome), + std::cout); + std::cout << "\n"; + return EXIT_SUCCESS; + } + + if (std::holds_alternative(outcome)) { + std::cerr << "The instance does not conform to the schema:\n"; + for (const auto &entry : + std::get(outcome)) { + std::cerr << " - " << entry.message << "\n at instance location \"" + << sourcemeta::core::to_string(entry.instance_location) + << "\"\n at evaluate path \"" + << sourcemeta::core::to_string(entry.evaluate_path) << "\"\n"; + } + return EXIT_FAILURE; + } + + const auto &error{ + std::get(outcome)}; + std::cerr << "The instance conforms but could not be resolved into JSON-LD:\n" + << " - " << error.message << "\n at instance location \"" + << sourcemeta::core::to_string(error.instance_location) << "\"\n"; + return EXIT_FAILURE; +} diff --git a/src/output/CMakeLists.txt b/src/output/CMakeLists.txt index 486d47cf9..099b543f1 100644 --- a/src/output/CMakeLists.txt +++ b/src/output/CMakeLists.txt @@ -1,10 +1,11 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT blaze NAME output FOLDER "Blaze/Output" - PRIVATE_HEADERS simple.h trace.h standard.h + PRIVATE_HEADERS simple.h trace.h standard.h jsonld.h SOURCES output_simple.cc output_trace.cc - output_standard.cc) + output_standard.cc + output_jsonld.cc) if(BLAZE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT blaze NAME output) @@ -14,6 +15,8 @@ target_link_libraries(sourcemeta_blaze_output PUBLIC sourcemeta::core::json) target_link_libraries(sourcemeta_blaze_output PUBLIC sourcemeta::core::jsonpointer) +target_link_libraries(sourcemeta_blaze_output PRIVATE + sourcemeta::core::jsonld) target_link_libraries(sourcemeta_blaze_output PUBLIC sourcemeta::blaze::foundation) target_link_libraries(sourcemeta_blaze_output PUBLIC diff --git a/src/output/include/sourcemeta/blaze/output.h b/src/output/include/sourcemeta/blaze/output.h index c0af589a8..fa8e7ace6 100644 --- a/src/output/include/sourcemeta/blaze/output.h +++ b/src/output/include/sourcemeta/blaze/output.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_BLAZE_OUTPUT_H_ #define SOURCEMETA_BLAZE_OUTPUT_H_ +#include #include #include #include diff --git a/src/output/include/sourcemeta/blaze/output_jsonld.h b/src/output/include/sourcemeta/blaze/output_jsonld.h new file mode 100644 index 000000000..01b97838b --- /dev/null +++ b/src/output/include/sourcemeta/blaze/output_jsonld.h @@ -0,0 +1,97 @@ +#ifndef SOURCEMETA_BLAZE_OUTPUT_JSONLD_H_ +#define SOURCEMETA_BLAZE_OUTPUT_JSONLD_H_ + +#ifndef SOURCEMETA_BLAZE_OUTPUT_EXPORT +#include +#endif + +#include +#include + +#include +#include + +#include // std::uint8_t +#include // std::string +#include // std::variant +#include // std::vector + +namespace sourcemeta::blaze { + +/// @ingroup output +/// The descriptor facet that a JSON-LD resolution error is about +enum class JSONLDFacet : std::uint8_t { Type, Predicate }; + +/// @ingroup output +/// The instance conforms but one of its JSON-LD annotations cannot be resolved +/// into a consistent mapping. Reported as a single failure at the instance +/// location where resolution stopped, distinct from a schema validation error. +struct JSONLDResolutionError { + sourcemeta::core::Pointer instance_location; + JSONLDFacet facet; + std::string message; +}; + +/// @ingroup output +/// The instance does not conform to the schema, so no JSON-LD is produced. +/// These are the schema validation errors. +using JSONLDInvalid = std::vector; + +/// @ingroup output +/// The tri-state outcome of a JSON-LD evaluation: the expanded document (as +/// expanded JSON-LD), schema validation errors, or a JSON-LD resolution error. +using JSONLDOutcome = + std::variant; + +/// @ingroup output +/// +/// Evaluate an instance against a JSON Schema whose atoms carry JSON-LD +/// annotations and, on success, return the instance promoted to expanded +/// JSON-LD. The schema must have been compiled with annotation collection +/// enabled for its JSON-LD keywords, as shown below. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// #include +/// #include +/// +/// #include +/// #include +/// +/// const sourcemeta::core::JSON schema = +/// sourcemeta::core::parse_json(R"JSON({ +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "object", +/// "x-jsonld-type": "https://schema.org/Person", +/// "properties": { +/// "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } +/// } +/// })JSON"); +/// +/// sourcemeta::blaze::Tweaks tweaks; +/// tweaks.annotations = std::unordered_set{ +/// "x-jsonld-id", "x-jsonld-type"}; +/// +/// const auto schema_template{sourcemeta::blaze::compile( +/// schema, sourcemeta::blaze::schema_walker, +/// sourcemeta::blaze::schema_resolver, +/// sourcemeta::blaze::default_schema_compiler, +/// sourcemeta::blaze::Mode::FastValidation, "", "", "", tweaks)}; +/// +/// const sourcemeta::core::JSON instance{ +/// sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; +/// +/// sourcemeta::blaze::Evaluator evaluator; +/// const auto outcome{ +/// sourcemeta::blaze::jsonld(evaluator, schema_template, instance)}; +/// ``` +auto SOURCEMETA_BLAZE_OUTPUT_EXPORT +jsonld(Evaluator &evaluator, const Template &schema, + const sourcemeta::core::JSON &instance) -> JSONLDOutcome; + +} // namespace sourcemeta::blaze + +#endif diff --git a/src/output/output_jsonld.cc b/src/output/output_jsonld.cc new file mode 100644 index 000000000..f454ef3e2 --- /dev/null +++ b/src/output/output_jsonld.cc @@ -0,0 +1,224 @@ +#include + +#include +#include +#include + +#include // std::ranges::sort, std::ranges::any_of +#include // std::ref +#include // std::tie +#include // std::unordered_map +#include // std::move +#include // std::variant, std::get, std::holds_alternative +#include // std::vector + +namespace { + +// The JSON-LD facts accumulated at one instance location, before a descriptor +// kind is derived from the instance value shape. +struct Facts { + std::vector edges; + std::vector types; +}; + +using Accumulator = std::unordered_map; + +// Default the undescribed elements of a collection: a scalar becomes a native +// literal, and a nested array becomes an inner (unordered) collection, whose +// own elements are filled the same way. As unordered collections, nested arrays +// flatten into their parent during materialization. Elements that carry their +// own annotations already have an accumulator entry and are left untouched. +auto fill_collection(sourcemeta::core::JSONLDWeakAnnotationMap &map, + const Accumulator &accumulator, + const sourcemeta::core::WeakPointer &pointer, + const sourcemeta::core::JSON &array) -> void { + for (std::size_t index = 0; index < array.size(); index += 1) { + auto element_pointer{pointer}; + element_pointer.push_back(index); + if (accumulator.contains(element_pointer)) { + continue; + } + + const auto &element{array.at(index)}; + if (element.is_array()) { + map.emplace( + element_pointer, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDCollection{}}); + fill_collection(map, accumulator, element_pointer, element); + } else if (!element.is_object()) { + map.emplace(std::move(element_pointer), + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDLiteral{}}); + } + } +} + +auto add_edge(std::vector &edges, + const sourcemeta::core::JSON::String &predicate, + const bool reverse) -> void { + const auto exists{std::ranges::any_of( + edges, + [&predicate, reverse](const sourcemeta::core::JSONLDEdge &edge) -> bool { + return edge.predicate == predicate && edge.reverse == reverse; + })}; + if (!exists) { + edges.push_back({.predicate = predicate, .reverse = reverse}); + } +} + +auto add_type(std::vector &types, + const sourcemeta::core::JSON::String &type) -> void { + const auto exists{std::ranges::any_of( + types, [&type](const sourcemeta::core::JSON::String &existing) -> bool { + return existing == type; + })}; + if (!exists) { + types.push_back(type); + } +} + +// Whether an annotation value is usable as an absolute IRI +auto is_iri_value(const sourcemeta::core::JSON &value) -> bool { + return value.is_string() && sourcemeta::core::URI::is_iri(value.to_string()); +} + +auto type_iri_error(const sourcemeta::core::WeakPointer &instance_location) + -> sourcemeta::blaze::JSONLDResolutionError { + return {.instance_location = sourcemeta::core::to_pointer(instance_location), + .facet = sourcemeta::blaze::JSONLDFacet::Type, + .message = "The value of x-jsonld-type must be an absolute IRI"}; +} + +// Turn the applicable x-jsonld-* annotations into a resolved annotation map, or +// into the first resolution error found. Only x-jsonld-id and x-jsonld-type are +// handled for now, so resolution fails on a malformed IRI value or on a type +// assigned to a position that does not denote a node. +auto resolve(const sourcemeta::core::JSON &instance, + const sourcemeta::blaze::SimpleOutput &output) + -> std::variant { + Accumulator accumulator; + + for (const auto &[location, values] : output.annotations()) { + if (location.evaluate_path.empty()) { + continue; + } + + const auto &keyword{location.evaluate_path.back().to_property()}; + auto &facts{accumulator[location.instance_location]}; + if (keyword == "x-jsonld-id") { + for (const auto &value : values) { + if (!is_iri_value(value)) { + return sourcemeta::blaze::JSONLDResolutionError{ + .instance_location = + sourcemeta::core::to_pointer(location.instance_location), + .facet = sourcemeta::blaze::JSONLDFacet::Predicate, + .message = "The value of x-jsonld-id must be an absolute IRI"}; + } + + add_edge(facts.edges, value.to_string(), false); + } + } else if (keyword == "x-jsonld-type") { + for (const auto &value : values) { + if (value.is_array()) { + for (const auto &element : value.as_array()) { + if (!is_iri_value(element)) { + return type_iri_error(location.instance_location); + } + + add_type(facts.types, element.to_string()); + } + } else { + if (!is_iri_value(value)) { + return type_iri_error(location.instance_location); + } + + add_type(facts.types, value.to_string()); + } + } + } + } + + sourcemeta::core::JSONLDWeakAnnotationMap map; + for (auto &[pointer, facts] : accumulator) { + std::ranges::sort(facts.edges, + [](const sourcemeta::core::JSONLDEdge &left, + const sourcemeta::core::JSONLDEdge &right) -> bool { + return std::tie(left.predicate, left.reverse) < + std::tie(right.predicate, right.reverse); + }); + std::ranges::sort(facts.types); + + const auto &value{sourcemeta::core::get(instance, pointer)}; + + // A type denotes an rdf:type, which only a node can carry + if (!value.is_object() && !facts.types.empty()) { + return sourcemeta::blaze::JSONLDResolutionError{ + .instance_location = sourcemeta::core::to_pointer(pointer), + .facet = sourcemeta::blaze::JSONLDFacet::Type, + .message = "A JSON-LD type can only be assigned to an object value"}; + } + + // A predicate is only meaningful on an object property, whose parent node + // it attaches to. The document root (an empty pointer) has no parent, and + // an array element (an index token) inherits the enclosing edge, so a + // predicate on either is an error. A type on such a position is fine when + // it is a node. + if (!facts.edges.empty() && + (pointer.empty() || !pointer.back().is_property())) { + return sourcemeta::blaze::JSONLDResolutionError{ + .instance_location = sourcemeta::core::to_pointer(pointer), + .facet = sourcemeta::blaze::JSONLDFacet::Predicate, + .message = pointer.empty() + ? "A JSON-LD predicate cannot be assigned to the " + "document root" + : "A JSON-LD predicate cannot be assigned to an array " + "element"}; + } + + sourcemeta::core::JSONLDDescriptor descriptor; + descriptor.edges = std::move(facts.edges); + if (value.is_object()) { + descriptor.value = + sourcemeta::core::JSONLDNode{.types = std::move(facts.types)}; + } else if (value.is_array()) { + descriptor.value = sourcemeta::core::JSONLDCollection{}; + } else { + descriptor.value = sourcemeta::core::JSONLDLiteral{}; + } + + map.emplace(pointer, std::move(descriptor)); + + if (value.is_array()) { + fill_collection(map, accumulator, pointer, value); + } + } + + return map; +} + +} // namespace + +namespace sourcemeta::blaze { + +auto jsonld(Evaluator &evaluator, const Template &schema, + const sourcemeta::core::JSON &instance) -> JSONLDOutcome { + SimpleOutput output{instance}; + const auto valid{evaluator.validate(schema, instance, std::ref(output))}; + if (!valid) { + return std::move(output).release(); + } + + auto resolved{resolve(instance, output)}; + if (std::holds_alternative(resolved)) { + return std::get(std::move(resolved)); + } + + const auto &map{ + std::get(resolved)}; + return sourcemeta::core::jsonld_materialize(instance, map); +} + +} // namespace sourcemeta::blaze diff --git a/test/output/CMakeLists.txt b/test/output/CMakeLists.txt index 6d8a14a3c..97fe8ad9c 100644 --- a/test/output/CMakeLists.txt +++ b/test/output/CMakeLists.txt @@ -2,10 +2,13 @@ sourcemeta_test(NAMESPACE sourcemeta PROJECT blaze NAME output SOURCES output_simple_test.cc output_standard_basic_test.cc - output_trace_test.cc) + output_trace_test.cc + output_jsonld_test.cc) target_link_libraries(sourcemeta_blaze_output_unit PRIVATE sourcemeta::core::json) +target_link_libraries(sourcemeta_blaze_output_unit + PRIVATE sourcemeta::core::jsonld) target_link_libraries(sourcemeta_blaze_output_unit PRIVATE sourcemeta::blaze::foundation) target_link_libraries(sourcemeta_blaze_output_unit diff --git a/test/output/output_jsonld_test.cc b/test/output/output_jsonld_test.cc new file mode 100644 index 000000000..348796328 --- /dev/null +++ b/test/output/output_jsonld_test.cc @@ -0,0 +1,1786 @@ +#include + +#include +#include +#include + +#include +#include +#include + +#include + +#include // std::unordered_set +#include // std::get, std::holds_alternative + +#define __JSON_LD_EVALUATE(schema, instance) \ + sourcemeta::blaze::Tweaks tweaks; \ + tweaks.annotations = std::unordered_set{ \ + "x-jsonld-id", "x-jsonld-type"}; \ + const auto schema_template{sourcemeta::blaze::compile( \ + (schema), sourcemeta::blaze::schema_walker, \ + sourcemeta::blaze::schema_resolver, \ + sourcemeta::blaze::default_schema_compiler, \ + sourcemeta::blaze::Mode::FastValidation, "", "", "", tweaks)}; \ + sourcemeta::blaze::Evaluator evaluator; \ + const auto outcome{ \ + sourcemeta::blaze::jsonld(evaluator, schema_template, (instance))}; + +#define EXPECT_JSON_LD_VALUE(schema, instance, expected) \ + { \ + __JSON_LD_EVALUATE(schema, instance) \ + EXPECT_TRUE(std::holds_alternative(outcome)); \ + const auto &document{std::get(outcome)}; \ + EXPECT_EQ(document, (expected)); \ + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); \ + } + +#define EXPECT_JSON_LD_INVALID(schema, instance, destination) \ + __JSON_LD_EVALUATE(schema, instance) \ + EXPECT_TRUE( \ + std::holds_alternative(outcome)); \ + const auto &destination{std::get(outcome)}; + +#define EXPECT_JSON_LD_ERROR(errors, index, expected_instance_location, \ + expected_evaluate_path, expected_schema_location, \ + expected_message) \ + EXPECT_TRUE((index) < (errors).size()); \ + EXPECT_EQ((errors).at(index).message, (expected_message)); \ + EXPECT_EQ(sourcemeta::core::to_string((errors).at(index).instance_location), \ + (expected_instance_location)); \ + EXPECT_EQ(sourcemeta::core::to_string((errors).at(index).evaluate_path), \ + (expected_evaluate_path)); \ + EXPECT_EQ((errors).at(index).schema_location.get(), \ + (expected_schema_location)); + +#define EXPECT_JSON_LD_RESOLUTION_ERROR(schema, instance, \ + expected_instance_location, \ + expected_facet, expected_message) \ + { \ + __JSON_LD_EVALUATE(schema, instance) \ + EXPECT_TRUE( \ + std::holds_alternative( \ + outcome)); \ + const auto &error{ \ + std::get(outcome)}; \ + EXPECT_EQ(sourcemeta::core::to_string(error.instance_location), \ + (expected_instance_location)); \ + EXPECT_EQ(error.facet, (expected_facet)); \ + EXPECT_EQ(error.message, (expected_message)); \ + } + +TEST(JSONLD_node_type_and_property_ids) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" }, + "email": { "type": "string", "x-jsonld-id": "https://schema.org/email" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "name": "Ada", "email": "ada@example.com" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Person" ], + "https://schema.org/email": [ { "@value": "ada@example.com" } ], + "https://schema.org/name": [ { "@value": "Ada" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_multiple_types_via_array) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": [ + "https://schema.org/Person", + "https://schema.org/Agent" + ], + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Agent", "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Ada" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_types_merge_via_allof) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "allOf": [ + { "x-jsonld-type": "https://schema.org/Person" }, + { "x-jsonld-type": "https://schema.org/Agent" } + ], + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Agent", "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Ada" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_types_dedup_via_ref_diamond) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "agent": { "x-jsonld-type": "https://schema.org/Agent" }, + "person": { + "allOf": [ { "$ref": "#/$defs/agent" } ], + "x-jsonld-type": "https://schema.org/Person" + } + }, + "type": "object", + "allOf": [ { "$ref": "#/$defs/agent" }, { "$ref": "#/$defs/person" } ] + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Agent", "https://schema.org/Person" ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_type_inherited_through_ref) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "person": { + "type": "object", + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } + } + } + }, + "type": "object", + "properties": { + "author": { + "$ref": "#/$defs/person", + "x-jsonld-id": "https://schema.org/author" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "author": { "name": "Ada" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/author": [ + { + "@type": [ "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Ada" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_recursive_ref) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "node": { + "type": "object", + "x-jsonld-type": "https://example.com/Node", + "properties": { + "value": { + "type": "string", + "x-jsonld-id": "https://example.com/value" + }, + "next": { + "$ref": "#/$defs/node", + "x-jsonld-id": "https://example.com/next" + } + } + } + }, + "$ref": "#/$defs/node" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ + "value": "a", + "next": { "value": "b", "next": { "value": "c" } } + })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://example.com/Node" ], + "https://example.com/next": [ + { + "@type": [ "https://example.com/Node" ], + "https://example.com/next": [ + { + "@type": [ "https://example.com/Node" ], + "https://example.com/value": [ { "@value": "c" } ] + } + ], + "https://example.com/value": [ { "@value": "b" } ] + } + ], + "https://example.com/value": [ { "@value": "a" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_array_of_nodes) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "friends": { + "type": "array", + "x-jsonld-id": "https://schema.org/knows", + "items": { + "type": "object", + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "friends": [ { "name": "Ada" }, { "name": "Bob" } ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/knows": [ + { + "@type": [ "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Ada" } ] + }, + { + "@type": [ "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Bob" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_conditional_then_branch) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "identifier": { + "if": { "type": "string" }, + "then": { "x-jsonld-id": "https://schema.org/name" }, + "else": { "x-jsonld-id": "https://schema.org/identifier" } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "identifier": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_conditional_else_branch) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "identifier": { + "if": { "type": "string" }, + "then": { "x-jsonld-id": "https://schema.org/name" }, + "else": { "x-jsonld-id": "https://schema.org/identifier" } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "identifier": 42 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/identifier": [ { "@value": 42 } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_oneof_only_matching_branch_survives) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { + "oneOf": [ + { "type": "integer", "x-jsonld-id": "https://schema.org/age" }, + { "type": "string", "x-jsonld-id": "https://schema.org/name" } + ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "value": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_standalone_node_without_edge) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "author": { + "type": "object", + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "author": { "name": "Ada" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Person" ], + "https://schema.org/name": [ { "@value": "Ada" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_invalid_instance_returns_validation_errors) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "name": { "type": "string", "x-jsonld-id": "https://schema.org/name" } + }, + "required": [ "name" ] + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + EXPECT_JSON_LD_INVALID(schema, instance, errors); + + EXPECT_EQ(errors.size(), 1); + EXPECT_JSON_LD_ERROR(errors, 0, "", "/required", "#/required", + "The value was expected to be an object that defines " + "the property \"name\""); +} + +TEST(JSONLD_type_on_literal_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "code": { + "type": "string", + "x-jsonld-type": "https://schema.org/Thing" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "code": "abc" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/code", sourcemeta::blaze::JSONLDFacet::Type, + "A JSON-LD type can only be assigned to an object value"); +} + +TEST(JSONLD_non_string_id_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "name": { "x-jsonld-id": 42 } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/name", sourcemeta::blaze::JSONLDFacet::Predicate, + "The value of x-jsonld-id must be an absolute IRI"); +} + +TEST(JSONLD_relative_id_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "name": { "x-jsonld-id": "name" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/name", sourcemeta::blaze::JSONLDFacet::Predicate, + "The value of x-jsonld-id must be an absolute IRI"); +} + +TEST(JSONLD_empty_type_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Type, + "The value of x-jsonld-type must be an absolute IRI"); +} + +TEST(JSONLD_type_on_array_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tags": { + "type": "array", + "x-jsonld-type": "https://schema.org/Thing" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "tags": [ "a", "b" ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/tags", sourcemeta::blaze::JSONLDFacet::Type, + "A JSON-LD type can only be assigned to an object value"); +} + +TEST(JSONLD_no_annotations_yields_empty_document) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "name": { "type": "string" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_null_value_produces_no_triple) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": null })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_falsy_scalar_literals_are_kept) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "active": { "x-jsonld-id": "https://schema.org/active" }, + "count": { "x-jsonld-id": "https://schema.org/count" }, + "label": { "x-jsonld-id": "https://schema.org/label" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "active": false, "count": 0, "label": "" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/active": [ { "@value": false } ], + "https://schema.org/count": [ { "@value": 0 } ], + "https://schema.org/label": [ { "@value": "" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_native_scalar_types) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "s": { "x-jsonld-id": "https://schema.org/s" }, + "i": { "x-jsonld-id": "https://schema.org/i" }, + "f": { "x-jsonld-id": "https://schema.org/f" }, + "b": { "x-jsonld-id": "https://schema.org/b" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "s": "x", "i": 5, "f": 1.5, "b": true })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/b": [ { "@value": true } ], + "https://schema.org/f": [ { "@value": 1.5 } ], + "https://schema.org/i": [ { "@value": 5 } ], + "https://schema.org/s": [ { "@value": "x" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_multiple_predicates_via_allof) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "label": { + "type": "string", + "allOf": [ + { "x-jsonld-id": "https://schema.org/name" }, + { "x-jsonld-id": "http://www.w3.org/2000/01/rdf-schema#label" } + ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "label": "Hi" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "http://www.w3.org/2000/01/rdf-schema#label": [ { "@value": "Hi" } ], + "https://schema.org/name": [ { "@value": "Hi" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_two_properties_same_predicate) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "firstName": { "x-jsonld-id": "https://schema.org/name" }, + "lastName": { "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "firstName": "Ada", "lastName": "Lovelace" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/name": [ + { "@value": "Ada" }, + { "@value": "Lovelace" } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_edge_dedup_via_allof) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "allOf": [ + { "x-jsonld-id": "https://schema.org/name" }, + { "x-jsonld-id": "https://schema.org/name" } + ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_empty_array_property_dropped) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tags": { "type": "array", "x-jsonld-id": "https://schema.org/keywords" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "tags": [] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_empty_type_array_is_untyped_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": [], + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_typed_root_with_empty_instance) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "https://schema.org/Person" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/Person" ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_absent_described_property) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" }, + "email": { "x-jsonld-id": "https://schema.org/email" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_property_names_annotation_ignored) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "propertyNames": { "x-jsonld-id": "https://schema.org/name" } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "a": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_unicode_value_preserved) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada 日本語" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada 日本語" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_array_of_scalars_emits_native_literals) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tags": { "type": "array", "x-jsonld-id": "https://schema.org/keywords" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "tags": [ "a", "b" ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/keywords": [ + { "@value": "a" }, + { "@value": "b" } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_annotation_under_not_is_dropped) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "v": { + "not": { "type": "integer", "x-jsonld-id": "https://schema.org/bad" } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "v": "hi" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_additional_properties_map_to_predicate) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": { "x-jsonld-id": "https://schema.org/extra" } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "a": "1", "b": "2" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/extra": [ + { "@value": "1" }, + { "@value": "2" } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_non_http_iri_schemes_accepted) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "urn:example:Thing", + "properties": { + "id": { "x-jsonld-id": "tag:example.com,2024:id" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "id": "x" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "urn:example:Thing" ], + "tag:example.com,2024:id": [ { "@value": "x" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_type_array_with_empty_element_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": [ "https://schema.org/A", "" ] + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Type, + "The value of x-jsonld-type must be an absolute IRI"); +} + +TEST(JSONLD_object_edge_without_described_children_is_empty_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "addr": { "type": "object", "x-jsonld-id": "https://schema.org/address" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "addr": { "street": "x" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/address": [ {} ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_dynamic_ref_recursive_tree) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/tree", + "$dynamicAnchor": "node", + "type": "object", + "x-jsonld-type": "https://schema.org/Node", + "properties": { + "kids": { + "type": "array", + "x-jsonld-id": "https://schema.org/child", + "items": { "$dynamicRef": "#node" } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ + "kids": [ { "kids": [] } ] + })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/Node" ], + "https://schema.org/child": [ + { "@type": [ "https://schema.org/Node" ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_unevaluated_properties_map_to_predicate) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a": { "x-jsonld-id": "https://schema.org/a" } + }, + "unevaluatedProperties": { "x-jsonld-id": "https://schema.org/rest" } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "a": "1", "b": "2" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/a": [ { "@value": "1" } ], + "https://schema.org/rest": [ { "@value": "2" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_property_name_requiring_pointer_escape) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a/b": { "x-jsonld-id": "https://schema.org/ab" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "a/b": "x" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/ab": [ { "@value": "x" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_resolution_error_location_is_pointer_escaped) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a~b": { "x-jsonld-type": "https://schema.org/T" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "a~b": "x" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/a~0b", sourcemeta::blaze::JSONLDFacet::Type, + "A JSON-LD type can only be assigned to an object value"); +} + +TEST(JSONLD_id_keyword_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "name": { "x-jsonld-id": "@id" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/name", sourcemeta::blaze::JSONLDFacet::Predicate, + "The value of x-jsonld-id must be an absolute IRI"); +} + +TEST(JSONLD_number_precision_preserved) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "big": { "x-jsonld-id": "https://schema.org/b" }, + "neg": { "x-jsonld-id": "https://schema.org/n" }, + "frac": { "x-jsonld-id": "https://schema.org/f" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "big": 1000000000000000000, "neg": -42, "frac": 0.1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/b": [ { "@value": 1000000000000000000 } ], + "https://schema.org/f": [ { "@value": 0.1 } ], + "https://schema.org/n": [ { "@value": -42 } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_array_of_numbers_and_booleans) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "nums": { "type": "array", "x-jsonld-id": "https://schema.org/n" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "nums": [ 1, 2, true ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/n": [ + { "@value": 1 }, + { "@value": 2 }, + { "@value": true } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_null_element_in_array_dropped) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tags": { "type": "array", "x-jsonld-id": "https://schema.org/k" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "tags": [ "a", null, "b" ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/k": [ { "@value": "a" }, { "@value": "b" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_boolean_subschema_and_closed_object) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" }, + "x": true + }, + "additionalProperties": false + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada", "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_anchor_reference) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "n": { "$anchor": "nm", "x-jsonld-id": "https://schema.org/name" } + }, + "type": "object", + "properties": { "name": { "$ref": "#nm" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "name": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_items_2019_09) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "p": { + "type": "array", + "x-jsonld-id": "https://schema.org/p", + "items": { "x-jsonld-type": "https://schema.org/Item" } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "p": [ {}, {} ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/p": [ + { "@type": [ "https://schema.org/Item" ] }, + { "@type": [ "https://schema.org/Item" ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_recursive_ref_2019_09) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.com/r", + "$recursiveAnchor": true, + "type": "object", + "x-jsonld-type": "https://schema.org/N", + "properties": { + "c": { "$recursiveRef": "#", "x-jsonld-id": "https://schema.org/c" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "c": { "c": {} } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/N" ], + "https://schema.org/c": [ + { + "@type": [ "https://schema.org/N" ], + "https://schema.org/c": [ { "@type": [ "https://schema.org/N" ] } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_const_value) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "kind": { "const": "book", "x-jsonld-id": "https://schema.org/kind" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "kind": "book" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/kind": [ { "@value": "book" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_property_names_type_annotation_ignored) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "propertyNames": { "x-jsonld-type": "https://schema.org/T" } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "a": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_empty_property_name) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "": { "x-jsonld-id": "https://schema.org/empty" } } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "": "x" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/empty": [ { "@value": "x" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_unicode_property_name) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "名前": { "x-jsonld-id": "https://schema.org/name" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "名前": "Ada" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/name": [ { "@value": "Ada" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_contains_subschema_types) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "list": { + "type": "array", + "x-jsonld-id": "https://schema.org/list", + "contains": { + "type": "object", + "x-jsonld-type": "https://schema.org/Item" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "list": [ { "a": 1 }, { "b": 2 } ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/list": [ + { "@type": [ "https://schema.org/Item" ] }, + { "@type": [ "https://schema.org/Item" ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_nested_array_flattens_as_set) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "m": { "type": "array", "x-jsonld-id": "https://schema.org/m" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "m": [ [ "a" ], [ "b" ] ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/m": [ { "@value": "a" }, { "@value": "b" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_deeply_nested_array_flattens_as_set) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "m": { "type": "array", "x-jsonld-id": "https://schema.org/m" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "m": [ [ [ "a" ], [ "b" ] ], [ "c" ] ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/m": [ + { "@value": "a" }, + { "@value": "b" }, + { "@value": "c" } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_edge_on_object_root_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-id": "https://schema.org/name", + "x-jsonld-type": "https://schema.org/Person" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Predicate, + "A JSON-LD predicate cannot be assigned to the document root"); +} + +TEST(JSONLD_edge_on_array_root_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "x-jsonld-id": "https://schema.org/items" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON([ "a", "b" ])JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Predicate, + "A JSON-LD predicate cannot be assigned to the document root"); +} + +TEST(JSONLD_instance_value_object_keys_are_not_special) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "v": { "x-jsonld-id": "https://schema.org/v" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "v": { "@value": "x", "@type": "y" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/v": [ {} ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_edge_on_scalar_root_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "x-jsonld-id": "https://schema.org/name" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON("Ada")JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Predicate, + "A JSON-LD predicate cannot be assigned to the document root"); +} + +TEST(JSONLD_polymorphic_oneof_selects_object_branch) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "v": { + "oneOf": [ + { + "type": "object", + "x-jsonld-type": "https://schema.org/A", + "properties": { + "x": { "x-jsonld-id": "https://schema.org/x" } + } + }, + { "type": "string", "x-jsonld-id": "https://schema.org/label" } + ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "v": { "x": "hi" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@type": [ "https://schema.org/A" ], + "https://schema.org/x": [ { "@value": "hi" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_pattern_properties_map_to_predicate) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x": { "x-jsonld-id": "https://schema.org/x" } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x1": "a", "y": "b" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/x": [ { "@value": "a" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_type_array_with_duplicates_deduped) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": [ + "https://schema.org/A", + "https://schema.org/A" + ] + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/A" ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_type_on_number_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "n": { "x-jsonld-type": "https://schema.org/T" } } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "n": 5 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/n", sourcemeta::blaze::JSONLDFacet::Type, + "A JSON-LD type can only be assigned to an object value"); +} + +TEST(JSONLD_non_string_type_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": 123 + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "", sourcemeta::blaze::JSONLDFacet::Type, + "The value of x-jsonld-type must be an absolute IRI"); +} + +TEST(JSONLD_whitespace_iri_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "x": { "x-jsonld-id": " https://example.com/p" } } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Predicate, + "The value of x-jsonld-id must be an absolute IRI"); +} + +TEST(JSONLD_complex_iri_accepted) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { "x-jsonld-id": "https://u@ex.com:8080/p?q=1#f" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://u@ex.com:8080/p?q=1#f": [ { "@value": "v" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_id_and_type_merge_across_allof_branches) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "author": { + "allOf": [ + { "x-jsonld-id": "https://schema.org/author" }, + { "type": "object", "x-jsonld-type": "https://schema.org/Person" } + ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "author": {} })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/author": [ + { "@type": [ "https://schema.org/Person" ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_tuple_items_default_to_literals) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "t": { + "type": "array", + "x-jsonld-id": "https://schema.org/t", + "prefixItems": [ { "type": "string" } ], + "items": { "type": "integer" } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "t": [ "a", 1, 2 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/t": [ + { "@value": "a" }, + { "@value": 1 }, + { "@value": 2 } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_unwhitelisted_keyword_is_ignored) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "d": { + "x-jsonld-id": "https://schema.org/d", + "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#date" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "d": "2024-01-01" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/d": [ { "@value": "2024-01-01" } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_evaluator_reused_across_calls) { + sourcemeta::blaze::Tweaks tweaks; + tweaks.annotations = std::unordered_set{ + "x-jsonld-id", "x-jsonld-type"}; + + const auto schema_first{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "a": { "x-jsonld-id": "https://schema.org/a" } } + })JSON")}; + const auto schema_second{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-jsonld-type": "https://schema.org/Person" + })JSON")}; + + const auto template_first{sourcemeta::blaze::compile( + schema_first, sourcemeta::blaze::schema_walker, + sourcemeta::blaze::schema_resolver, + sourcemeta::blaze::default_schema_compiler, + sourcemeta::blaze::Mode::FastValidation, "", "", "", tweaks)}; + const auto template_second{sourcemeta::blaze::compile( + schema_second, sourcemeta::blaze::schema_walker, + sourcemeta::blaze::schema_resolver, + sourcemeta::blaze::default_schema_compiler, + sourcemeta::blaze::Mode::FastValidation, "", "", "", tweaks)}; + + const auto instance_first{ + sourcemeta::core::parse_json(R"JSON({ "a": "x" })JSON")}; + const auto instance_second{sourcemeta::core::parse_json(R"JSON({})JSON")}; + + sourcemeta::blaze::Evaluator evaluator; + + const auto outcome_first{ + sourcemeta::blaze::jsonld(evaluator, template_first, instance_first)}; + const auto outcome_second{ + sourcemeta::blaze::jsonld(evaluator, template_second, instance_second)}; + + EXPECT_TRUE(std::holds_alternative(outcome_first)); + EXPECT_EQ(std::get(outcome_first), + sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/a": [ { "@value": "x" } ] } + ])JSON")); + + EXPECT_TRUE(std::holds_alternative(outcome_second)); + EXPECT_EQ(std::get(outcome_second), + sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/Person" ] } + ])JSON")); +} + +TEST(JSONLD_anyof_multiple_matching_branches_merge_types) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "v": { + "anyOf": [ + { "x-jsonld-type": "https://schema.org/A" }, + { "x-jsonld-type": "https://schema.org/B" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "v": {} })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/A", "https://schema.org/B" ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_ref_chain) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "c": { "x-jsonld-type": "https://schema.org/C" }, + "b": { "$ref": "#/$defs/c" }, + "a": { "$ref": "#/$defs/b" } + }, + "type": "object", + "properties": { "x": { "$ref": "#/$defs/a" } } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": {} })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/C" ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_int64_max_preserved) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "n": { "x-jsonld-id": "https://schema.org/n" } } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "n": 9223372036854775807 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "https://schema.org/n": [ { "@value": 9223372036854775807 } ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_overlapping_property_definitions_merge_edges) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x1": { "x-jsonld-id": "https://schema.org/a" } + }, + "patternProperties": { + "^x": { "x-jsonld-id": "https://schema.org/b" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x1": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/a": [ { "@value": "v" } ], + "https://schema.org/b": [ { "@value": "v" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_dependent_schemas) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cc": { "x-jsonld-id": "https://schema.org/cc" } + }, + "dependentSchemas": { + "cc": { + "properties": { + "exp": { "x-jsonld-id": "https://schema.org/exp" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "cc": "1234", "exp": "12/25" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/cc": [ { "@value": "1234" } ], + "https://schema.org/exp": [ { "@value": "12/25" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_ref_with_escaped_pointer) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a/b": { "x-jsonld-type": "https://schema.org/T" } + }, + "type": "object", + "properties": { "x": { "$ref": "#/$defs/a~1b" } } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": {} })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { "@type": [ "https://schema.org/T" ] } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_edge_on_collection_element_is_a_resolution_error) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "pair": { + "type": "array", + "x-jsonld-id": "https://schema.org/pair", + "prefixItems": [ { "x-jsonld-id": "https://schema.org/first" } ] + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "pair": [ "a" ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/pair/0", sourcemeta::blaze::JSONLDFacet::Predicate, + "A JSON-LD predicate cannot be assigned to an array element"); +} From bd30e65338e897dfa87154b5705920bf766be197 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Mon, 6 Jul 2026 16:02:20 -0300 Subject: [PATCH 2/2] More Signed-off-by: Juan Cruz Viotti --- src/output/output_jsonld.cc | 7 +++ test/output/output_jsonld_test.cc | 78 ++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/output/output_jsonld.cc b/src/output/output_jsonld.cc index f454ef3e2..3d0b08448 100644 --- a/src/output/output_jsonld.cc +++ b/src/output/output_jsonld.cc @@ -107,6 +107,13 @@ auto resolve(const sourcemeta::core::JSON &instance, } const auto &keyword{location.evaluate_path.back().to_property()}; + // Only the keywords resolved below may contribute facts. An unhandled + // keyword must not create an accumulator entry, as the empty facts would + // otherwise materialize a spurious node or literal descriptor + if (keyword != "x-jsonld-id" && keyword != "x-jsonld-type") { + continue; + } + auto &facts{accumulator[location.instance_location]}; if (keyword == "x-jsonld-id") { for (const auto &value : values) { diff --git a/test/output/output_jsonld_test.cc b/test/output/output_jsonld_test.cc index 348796328..fdfeb209a 100644 --- a/test/output/output_jsonld_test.cc +++ b/test/output/output_jsonld_test.cc @@ -13,10 +13,10 @@ #include // std::unordered_set #include // std::get, std::holds_alternative -#define __JSON_LD_EVALUATE(schema, instance) \ +#define JSON_LD_EVALUATE_WITH(schema, instance, ...) \ sourcemeta::blaze::Tweaks tweaks; \ - tweaks.annotations = std::unordered_set{ \ - "x-jsonld-id", "x-jsonld-type"}; \ + tweaks.annotations = \ + std::unordered_set{__VA_ARGS__}; \ const auto schema_template{sourcemeta::blaze::compile( \ (schema), sourcemeta::blaze::schema_walker, \ sourcemeta::blaze::schema_resolver, \ @@ -26,17 +26,24 @@ const auto outcome{ \ sourcemeta::blaze::jsonld(evaluator, schema_template, (instance))}; -#define EXPECT_JSON_LD_VALUE(schema, instance, expected) \ +#define JSON_LD_EVALUATE(schema, instance) \ + JSON_LD_EVALUATE_WITH(schema, instance, "x-jsonld-id", "x-jsonld-type") + +#define EXPECT_JSON_LD_VALUE_WITH(schema, instance, expected, ...) \ { \ - __JSON_LD_EVALUATE(schema, instance) \ + JSON_LD_EVALUATE_WITH(schema, instance, __VA_ARGS__) \ EXPECT_TRUE(std::holds_alternative(outcome)); \ const auto &document{std::get(outcome)}; \ EXPECT_EQ(document, (expected)); \ EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); \ } +#define EXPECT_JSON_LD_VALUE(schema, instance, expected) \ + EXPECT_JSON_LD_VALUE_WITH(schema, instance, expected, "x-jsonld-id", \ + "x-jsonld-type") + #define EXPECT_JSON_LD_INVALID(schema, instance, destination) \ - __JSON_LD_EVALUATE(schema, instance) \ + JSON_LD_EVALUATE(schema, instance) \ EXPECT_TRUE( \ std::holds_alternative(outcome)); \ const auto &destination{std::get(outcome)}; @@ -57,7 +64,7 @@ expected_instance_location, \ expected_facet, expected_message) \ { \ - __JSON_LD_EVALUATE(schema, instance) \ + JSON_LD_EVALUATE(schema, instance) \ EXPECT_TRUE( \ std::holds_alternative( \ outcome)); \ @@ -1784,3 +1791,60 @@ TEST(JSONLD_edge_on_collection_element_is_a_resolution_error) { schema, instance, "/pair/0", sourcemeta::blaze::JSONLDFacet::Predicate, "A JSON-LD predicate cannot be assigned to an array element"); } + +TEST(JSONLD_array_of_undescribed_objects_with_described_children) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "friends": { + "type": "array", + "x-jsonld-id": "https://schema.org/knows", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json( + R"JSON({ "friends": [ { "name": "Ada" }, { "name": "Bob" } ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/knows": [ + { "https://schema.org/name": [ { "@value": "Ada" } ] }, + { "https://schema.org/name": [ { "@value": "Bob" } ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_collected_but_unhandled_keyword_creates_no_descriptor) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "meta": { + "type": "object", + "x-jsonld-container": "@index" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "meta": { "a": 1 } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE_WITH(schema, instance, expected, "x-jsonld-id", + "x-jsonld-type", "x-jsonld-container"); +}