From 2fef59559370e2c23c8d0695dea4f6fbe3bbf7e9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 2 Aug 2026 19:45:03 -0300 Subject: [PATCH 1/3] Implement `x-jsonld-value` and `x-jsonld-constants` keywords Signed-off-by: Juan Cruz Viotti --- .../v1/2019-09/vocabulary.json | 104 + .../v1/2020-12/vocabulary.json | 104 + .../sourcemeta-extension/v1/README.markdown | 79 +- .../v1/vocabulary.test.json | 287 +++ .../include/sourcemeta/blaze/output_jsonld.h | 10 +- src/output/output_jsonld.cc | 454 +++- test/output/output_jsonld_test.cc | 2273 +++++++++++++++++ 7 files changed, 3280 insertions(+), 31 deletions(-) diff --git a/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json index 5f11cd77c..d9510d8af 100644 --- a/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json +++ b/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -32,6 +32,28 @@ "x-jsonld-container": { "enum": [ "@list", "@set", "@language", "@index", null ] }, + "x-jsonld-constants": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "$comment": "Node types and identifiers have their own keywords, so a fragment key cannot be a JSON-LD keyword or the rdf:type predicate", + "x-format-assertion": true, + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "pattern": "^[^@]", + "format": "iri" + }, + "additionalProperties": { + "$ref": "#/$defs/constants-entry" + } + } + ] + }, "x-jsonld-datatype": { "anyOf": [ { @@ -127,6 +149,88 @@ } } ] + }, + "x-jsonld-value": { + "anyOf": [ + { + "type": "null" + }, + { + "$comment": "The rdf:type predicate would mint type edges to literals, and node types are declared with x-jsonld-type", + "x-format-assertion": true, + "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "format": "iri" + } + ] + } + }, + "$defs": { + "constants-entry": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/constants-term" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/constants-term" + } + } + ] + }, + "constants-term": { + "anyOf": [ + { + "type": [ "string", "number", "boolean" ] + }, + { + "type": "object", + "required": [ "@id" ], + "properties": { + "@id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value" ], + "properties": { + "@value": { + "type": [ "string", "number", "boolean" ] + }, + "@type": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value", "@language" ], + "properties": { + "@value": { + "type": "string" + }, + "@language": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + } + }, + "additionalProperties": false + } + ] } } } diff --git a/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json index efe43e3ff..65cebc488 100644 --- a/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json +++ b/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -32,6 +32,28 @@ "x-jsonld-container": { "enum": [ "@list", "@set", "@language", "@index", null ] }, + "x-jsonld-constants": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "$comment": "Node types and identifiers have their own keywords, so a fragment key cannot be a JSON-LD keyword or the rdf:type predicate", + "x-format-assertion": true, + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "pattern": "^[^@]", + "format": "iri" + }, + "additionalProperties": { + "$ref": "#/$defs/constants-entry" + } + } + ] + }, "x-jsonld-datatype": { "anyOf": [ { @@ -127,6 +149,88 @@ } } ] + }, + "x-jsonld-value": { + "anyOf": [ + { + "type": "null" + }, + { + "$comment": "The rdf:type predicate would mint type edges to literals, and node types are declared with x-jsonld-type", + "x-format-assertion": true, + "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "format": "iri" + } + ] + } + }, + "$defs": { + "constants-entry": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/constants-term" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/constants-term" + } + } + ] + }, + "constants-term": { + "anyOf": [ + { + "type": [ "string", "number", "boolean" ] + }, + { + "type": "object", + "required": [ "@id" ], + "properties": { + "@id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value" ], + "properties": { + "@value": { + "type": [ "string", "number", "boolean" ] + }, + "@type": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value", "@language" ], + "properties": { + "@value": { + "type": "string" + }, + "@language": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + } + }, + "additionalProperties": false + } + ] } } } diff --git a/schemas/sourcemeta-extension/v1/README.markdown b/schemas/sourcemeta-extension/v1/README.markdown index 19a4153f8..386a05acc 100644 --- a/schemas/sourcemeta-extension/v1/README.markdown +++ b/schemas/sourcemeta-extension/v1/README.markdown @@ -37,6 +37,8 @@ new vocabulary published under a new major version with a new URI. | [`x-jsonld-container`](#329-x-jsonld-container) | `@list`, `@set`, `@language`, or `@index` | Array or object property subschemas | | [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template | Scalar or object subschemas | | [`x-jsonld-override`](#3211-x-jsonld-override) | A boolean | Any subschema | +| [`x-jsonld-value`](#3212-x-jsonld-value) | An absolute IRI | Scalar subschemas | +| [`x-jsonld-constants`](#3213-x-jsonld-constants) | An expanded-form fragment | Object or promoted scalar subschemas | ### 3.1. `x-format-assertion` @@ -81,8 +83,8 @@ This keyword declares the `@type` of the node that the annotated instance location materializes as. A single IRI is equivalent to an array containing only that IRI, values that meet at the same location merge into the union of the declared types, and an empty array declares no types. It MUST be applied -to a location whose value is an object, unless the location is promoted to an -identified node with `x-jsonld-self`. +to a location whose value is an object, unless the location is promoted to a +node with `x-jsonld-self` or `x-jsonld-value`. #### 3.2.3. `x-jsonld-reverse` @@ -206,17 +208,76 @@ Within an override-marked schema object, every keyword of this vocabulary also accepts `null`, which removes instead of declaring: - A `null` for `x-jsonld-datatype`, `x-jsonld-language`, `x-jsonld-direction`, - `x-jsonld-container`, `x-jsonld-self`, `x-jsonld-json`, or `x-jsonld-graph` - overrides like any other value and restores the behavior the location has - when the keyword is absent. -- A `null` for `x-jsonld-id`, `x-jsonld-reverse`, or `x-jsonld-type` removes - every value of that keyword reaching the location from beneath the object, - while values from anywhere else are kept. These keywords otherwise - accumulate, and their non-null values are unaffected by overriding. + `x-jsonld-container`, `x-jsonld-self`, `x-jsonld-json`, `x-jsonld-graph`, + or `x-jsonld-value` overrides like any other value and restores the + behavior the location has when the keyword is absent. Removing a promotion + requires tombstoning `x-jsonld-value`, `x-jsonld-type`, and + `x-jsonld-constants` together, as a lone `x-jsonld-value` tombstone + strands the other two facets into placement errors. +- A `null` for `x-jsonld-id`, `x-jsonld-reverse`, `x-jsonld-type`, or + `x-jsonld-constants` removes every value of that keyword reaching the + location from beneath the object, while values from anywhere else are + kept. These keywords otherwise accumulate, and their non-null values are + unaffected by overriding. A `null` member inside an `x-jsonld-constants` + fragment removes that one key's entries reaching the location from + beneath the object, and is only legal inside an override-marked schema + object. Outside an override-marked schema object, a `null` value for any keyword of this vocabulary declares nothing, and resolves as if the keyword were absent. +#### 3.2.12. `x-jsonld-value` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987], and MUST NOT be the IRI +`http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, as node types are +declared with `x-jsonld-type`. + +This keyword promotes the annotated scalar instance location into a node +that carries the scalar as a literal under the declared predicate, with the +native JSON value preserved. It MUST be applied to a location whose value is +a scalar. `x-jsonld-type` becomes legal on the promoted location, typing the +promoted node, and `x-jsonld-self` names it. A promoted node with no +incoming predicate and no self identity is omitted entirely, and a `null` +value at a promoted location materializes nothing, as constant properties +never appear without the scalar that legitimizes them. + +When this keyword fuses with `x-jsonld-datatype`, `x-jsonld-language`, +`x-jsonld-direction`, or `x-jsonld-self` at the same location, those +keywords follow the scalar into the promoted node, typing or tagging the +inner literal or naming the node. That reshaping requires consent: the +other keyword MUST be declared in the same schema object as this keyword, +or this keyword MUST be declared in an override-marked schema object whose +subschemas declare the other keyword. Any other fusion is an error. This +keyword MUST NOT be combined with `x-jsonld-json`, `x-jsonld-graph`, or +`x-jsonld-container` at the same location. + +#### 3.2.13. `x-jsonld-constants` + +The value of this keyword MUST be an object representing a fragment of an +expanded-form JSON-LD node object. Its keys MUST be absolute predicate IRIs +[RFC3987], and MUST NOT be JSON-LD keywords nor the IRI +`http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, as node types and node +identifiers have their own keywords. Its members MUST be expanded-form +terms, or arrays of them: a bare string, number, or boolean is a literal, a +bare string never an IRI, an object with a single `@id` member holding an +absolute IRI is a node reference, and an object with a mandatory non-null +scalar `@value` member and at most one of an `@type` member holding an +absolute IRI or an `@language` member holding a canonical language tag in +the formatting of [RFC5646] is a literal in explicit form. An empty array +member asserts nothing, and the empty fragment is valid. + +This keyword merges constant properties into the node that the annotated +instance location materializes as. Each entry is an ordinary edge whose +object is fixed by the schema, indistinguishable in the output from +instance-derived edges. It MUST be applied to a location whose value is an +object, or to a scalar location promoted by `x-jsonld-value` or +`x-jsonld-self`. Fragments that meet at the same location merge: entries +union by key, and the terms under one key union and deduplicate after +normalization to canonical expanded form, so a term and its singleton array +spelling are one value. On a location whose edges are asserted inside a +named `@graph`, constant properties land on the inner subject. + 4. References ------------- diff --git a/schemas/sourcemeta-extension/v1/vocabulary.test.json b/schemas/sourcemeta-extension/v1/vocabulary.test.json index 59fcc4dd2..2e6f06651 100644 --- a/schemas/sourcemeta-extension/v1/vocabulary.test.json +++ b/schemas/sourcemeta-extension/v1/vocabulary.test.json @@ -1004,6 +1004,293 @@ "x-jsonld-datatype": 123, "x-jsonld-override": true } + }, + { + "description": "x-jsonld-value with IRI is valid", + "valid": true, + "data": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + }, + { + "description": "x-jsonld-value with null is valid", + "valid": true, + "data": { + "x-jsonld-value": null + } + }, + { + "description": "x-jsonld-value with relative reference is rejected", + "valid": false, + "data": { + "x-jsonld-value": "value" + } + }, + { + "description": "x-jsonld-value with number is rejected", + "valid": false, + "data": { + "x-jsonld-value": 1 + } + }, + { + "description": "x-jsonld-value with the rdf:type predicate is rejected", + "valid": false, + "data": { + "x-jsonld-value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + } + }, + { + "description": "x-jsonld-constants with null is valid", + "valid": true, + "data": { + "x-jsonld-constants": null + } + }, + { + "description": "x-jsonld-constants with empty object is valid", + "valid": true, + "data": { + "x-jsonld-constants": {} + } + }, + { + "description": "x-jsonld-constants with node reference entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + }, + { + "description": "x-jsonld-constants with scalar literal entries is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/text": "plain", + "https://example.com/count": 2, + "https://example.com/flag": true + } + } + }, + { + "description": "x-jsonld-constants with typed literal entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/factor": { + "@value": "0.1", + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + } + } + } + }, + { + "description": "x-jsonld-constants with language tagged entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/label": { + "@value": "metre", + "@language": "en" + } + } + } + }, + { + "description": "x-jsonld-constants with array entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/seeAlso": [ + { + "@id": "https://example.com/a" + }, + "note" + ] + } + } + }, + { + "description": "x-jsonld-constants with empty array entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/seeAlso": [] + } + } + }, + { + "description": "x-jsonld-constants with null entry is valid", + "valid": true, + "data": { + "x-jsonld-constants": { + "https://example.com/unit": null + } + } + }, + { + "description": "x-jsonld-constants with array value is rejected", + "valid": false, + "data": { + "x-jsonld-constants": [] + } + }, + { + "description": "x-jsonld-constants with string value is rejected", + "valid": false, + "data": { + "x-jsonld-constants": "not a fragment" + } + }, + { + "description": "x-jsonld-constants with type keyword key is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "@type": "http://qudt.org/schema/qudt/Quantity" + } + } + }, + { + "description": "x-jsonld-constants with rdf:type predicate key is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { + "@id": "http://qudt.org/schema/qudt/Quantity" + } + } + } + }, + { + "description": "x-jsonld-constants with identifier keyword key is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "@id": "https://example.com/a" + } + } + }, + { + "description": "x-jsonld-constants with relative key is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "unit": { + "@id": "https://example.com/a" + } + } + } + }, + { + "description": "x-jsonld-constants node reference with extra member is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/seeAlso": { + "@id": "https://example.com/a", + "https://example.com/comment": "x" + } + } + } + }, + { + "description": "x-jsonld-constants blank node reference is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/seeAlso": { + "@id": "_:b0" + } + } + } + }, + { + "description": "x-jsonld-constants value object with type and language is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/label": { + "@value": "metre", + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@language": "en" + } + } + } + }, + { + "description": "x-jsonld-constants language on non-string value is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/label": { + "@value": 1, + "@language": "en" + } + } + } + }, + { + "description": "x-jsonld-constants non-canonical language is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/label": { + "@value": "metre", + "@language": "en-us" + } + } + } + }, + { + "description": "x-jsonld-constants JSON literal type is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/data": { + "@value": "x", + "@type": "@json" + } + } + } + }, + { + "description": "x-jsonld-constants value object with unknown member is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/status": { + "@value": "x", + "@index": "a" + } + } + } + }, + { + "description": "x-jsonld-constants null value object is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/status": { + "@value": null + } + } + } + }, + { + "description": "x-jsonld-constants list term is rejected", + "valid": false, + "data": { + "x-jsonld-constants": { + "https://example.com/list": { + "@list": [ 1, 2 ] + } + } + } } ] } diff --git a/src/output/include/sourcemeta/blaze/output_jsonld.h b/src/output/include/sourcemeta/blaze/output_jsonld.h index b2799d47a..1f072ab49 100644 --- a/src/output/include/sourcemeta/blaze/output_jsonld.h +++ b/src/output/include/sourcemeta/blaze/output_jsonld.h @@ -23,12 +23,12 @@ namespace sourcemeta::blaze { /// @ingroup output /// The x-jsonld-* keywords that jsonld() resolves. Use these as the annotation /// whitelist when compiling a schema so that its annotations are collected. -inline constexpr std::array +inline constexpr std::array JSONLD_KEYWORDS{{"x-jsonld-id", "x-jsonld-type", "x-jsonld-reverse", "x-jsonld-datatype", "x-jsonld-language", "x-jsonld-direction", "x-jsonld-json", "x-jsonld-graph", - "x-jsonld-container", "x-jsonld-self", - "x-jsonld-override"}}; + "x-jsonld-container", "x-jsonld-self", "x-jsonld-override", + "x-jsonld-value", "x-jsonld-constants"}}; /// @ingroup output /// The descriptor facet that a JSON-LD resolution error is about @@ -42,7 +42,9 @@ enum class JSONLDFacet : std::uint8_t { JSON, Container, Self, - Override + Override, + ValuePredicate, + Constants }; /// @ingroup output diff --git a/src/output/output_jsonld.cc b/src/output/output_jsonld.cc index 72c6dfa2e..fee16cf43 100644 --- a/src/output/output_jsonld.cc +++ b/src/output/output_jsonld.cc @@ -6,8 +6,8 @@ #include #include -#include // std::ranges::sort, std::ranges::any_of -#include // assert +#include // std::ranges::sort, std::ranges::any_of, std::ranges::find +#include // assert #include // std::ref, std::reference_wrapper #include // std::initializer_list #include // std::optional @@ -26,7 +26,9 @@ namespace { // facet remembers the schema location of the annotation that last changed it, // never of a redundant duplicate, so a later error about the facet can cite // its origin. The origins are plain pointers into the collected annotation -// entries, so carrying them costs no allocation +// entries, so carrying them costs no allocation. The value predicate and the +// literal facets additionally remember their evaluate paths, as retargeting +// consent compares schema object placement struct Facts { std::vector edges; std::vector types; @@ -35,8 +37,11 @@ struct Facts { std::optional direction; std::optional container; std::optional self; + std::optional value; + std::optional constants; bool json{false}; bool graph{false}; + bool value_marked{false}; const std::string *edges_origin{nullptr}; const std::string *reverse_origin{nullptr}; const std::string *types_origin{nullptr}; @@ -47,6 +52,13 @@ struct Facts { const std::string *self_origin{nullptr}; const std::string *json_origin{nullptr}; const std::string *graph_origin{nullptr}; + const std::string *value_origin{nullptr}; + const std::string *constants_origin{nullptr}; + const sourcemeta::core::WeakPointer *value_path{nullptr}; + const sourcemeta::core::WeakPointer *self_path{nullptr}; + const sourcemeta::core::WeakPointer *datatype_path{nullptr}; + const sourcemeta::core::WeakPointer *language_path{nullptr}; + const sourcemeta::core::WeakPointer *direction_path{nullptr}; }; using Accumulator = std::unordered_map bool { return value.is_string() && sourcemeta::core::URI::is_iri(value.to_string()); } +// Whether a canonical constants fragment carries a null entry, whose +// legality depends on override marks that only the slow path can consult +auto fragment_has_null_entry(const sourcemeta::core::JSON &fragment) -> bool { + for (const auto &entry : fragment.as_object()) { + if (entry.second.is_null()) { + return true; + } + } + + return false; +} + +// Union one canonical constants entry into the accumulated constants map, +// unioning and deduplicating the terms under its key. Returns whether +// anything new was contributed +auto merge_constants_entry(std::optional &accumulated, + const sourcemeta::core::JSON::String &key, + const sourcemeta::core::JSON &terms) -> bool { + if (!accumulated.has_value()) { + accumulated = sourcemeta::core::JSON::make_object(); + } + + if (!accumulated->defines(key)) { + accumulated->assign(sourcemeta::core::JSON::String{key}, + sourcemeta::core::JSON{terms}); + return true; + } + + bool contributed{false}; + auto &existing{accumulated->at(key)}; + for (const auto &term : terms.as_array()) { + if (std::ranges::find(existing.as_array(), term) == + existing.as_array().cend()) { + existing.push_back(sourcemeta::core::JSON{term}); + contributed = true; + } + } + + return contributed; +} + +// Union a canonical constants fragment into the accumulated constants map by +// key. Returns whether anything new was contributed. The caller guarantees +// the fragment carries no null entries +auto merge_constants_fragment( + std::optional &accumulated, + const sourcemeta::core::JSON &fragment) -> bool { + bool contributed{false}; + for (const auto &entry : fragment.as_object()) { + contributed = + merge_constants_entry(accumulated, entry.first, entry.second) || + contributed; + } + + return contributed; +} + +// The constants map a node-producing descriptor takes: the accumulated +// canonical fragment, or the empty map the descriptor kinds default to +auto take_constants(Facts &facts) -> sourcemeta::core::JSON { + if (facts.constants.has_value()) { + return std::move(facts.constants).value(); + } + + return sourcemeta::core::JSON::make_object(); +} + using DirtyLocations = std::unordered_set; @@ -137,6 +216,24 @@ auto facet_error( .inert_override_location = std::move(inert_override_location)}; } +// Validate and normalize an x-jsonld-constants fragment into canonical +// expanded form, wrapping the fragment grammar violation, if any, as a +// resolution error citing the annotation +auto canonicalize_constants( + const sourcemeta::core::JSON &fragment, + const sourcemeta::core::WeakPointer &instance_location, + const std::string &origin) + -> std::variant { + try { + return sourcemeta::core::jsonld_canonicalize_fragment(fragment); + } catch (const sourcemeta::core::JSONLDFragmentError &error) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::Constants, error.what(), + origin); + } +} + auto parse_direction(const sourcemeta::core::JSON &value) -> std::optional { if (!value.is_string()) { @@ -245,7 +342,8 @@ auto placement_error(const sourcemeta::core::WeakPointer &pointer, if (!value.is_object()) { // A self identity promotes a scalar to a reference, which carries its own // types, so a type is only misplaced on a scalar that has no self identity - if (!facts.types.empty() && !facts.self.has_value()) { + if (!facts.types.empty() && !facts.self.has_value() && + !facts.value.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Type, "A JSON-LD type can only be assigned to an object value", @@ -426,7 +524,8 @@ auto array_of_nodes(const Accumulator &accumulator, element_pointer.push_back(index); const auto element_facts{accumulator.find(element_pointer)}; if (element_facts == accumulator.cend() || - !element_facts->second.self.has_value()) { + (!element_facts->second.self.has_value() && + !element_facts->second.value.has_value())) { return false; } } @@ -444,6 +543,24 @@ auto encloses(const sourcemeta::core::WeakPointer &outer, return outer.size() < inner.size() && inner.starts_with_initial(outer); } +// Whether two annotations share their schema object, that is, their evaluate +// paths differ at most in the trailing keyword token +auto same_schema_object(const sourcemeta::core::WeakPointer &left, + const sourcemeta::core::WeakPointer &right) -> bool { + return left.size() == right.size() && right.starts_with_initial(left); +} + +// Whether the value predicate may reshape another facet of its location: +// either both were declared in one schema object, one author's intentional +// spelling, or the value predicate is override-marked and its schema object +// encloses the other facet's, a composer restructuring what it composes +auto retargeting_consented(const Facts &facts, + const sourcemeta::core::WeakPointer &facet_path) + -> bool { + return same_schema_object(*facts.value_path, facet_path) || + (facts.value_marked && encloses(*facts.value_path, facet_path)); +} + // An x-jsonld-override annotation that evaluated to true at a location, // paired with the schema location of the mark itself for error citations struct Mark { @@ -474,6 +591,8 @@ struct Pending { std::vector graphs; std::vector containers; std::vector selves; + std::vector values; + std::vector constants; std::vector marks; [[nodiscard]] auto annotated() const -> bool { @@ -481,7 +600,8 @@ struct Pending { !this->types.empty() || !this->datatypes.empty() || !this->languages.empty() || !this->directions.empty() || !this->jsons.empty() || !this->graphs.empty() || - !this->containers.empty() || !this->selves.empty(); + !this->containers.empty() || !this->selves.empty() || + !this->values.empty() || !this->constants.empty(); } }; @@ -698,6 +818,95 @@ auto resolve_types(const std::vector &candidates, Facts &facts) } } +// Whether a constants contribution under the given key is removed by a +// marked tombstone whose schema object encloses the contributor's: either a +// null for the whole keyword or a null entry for that key +auto constants_key_removed(const std::vector &candidates, + const std::vector &fragments, + const Candidate &candidate, + const sourcemeta::core::JSON::String &key) -> bool { + for (std::size_t index = 0; index < candidates.size(); index += 1) { + const auto &tombstone{candidates[index]}; + if (tombstone.mark == nullptr || + !encloses(*tombstone.path, *candidate.path)) { + continue; + } + + const auto &fragment{fragments[index]}; + if (fragment.is_null()) { + return true; + } + + const auto *entry{fragment.try_at(key)}; + if (entry != nullptr && entry->is_null()) { + return true; + } + } + + return false; +} + +// Resolve the additive constants facet: canonical fragments union by key, +// with per-key term union and dedupe. A marked null for the whole keyword +// tombstones every entry collected beneath its schema object, and a marked +// null entry tombstones its own key beneath, while a null entry outside an +// overriding schema object is an error +auto resolve_constants(const sourcemeta::core::WeakPointer &location, + const std::vector &candidates, Facts &facts) + -> std::optional { + if (candidates.empty()) { + return std::nullopt; + } + + // The first pass validated every fragment, so canonicalization cannot + // fail here + std::vector fragments; + fragments.reserve(candidates.size()); + for (const auto &candidate : candidates) { + if (candidate.value->is_null()) { + fragments.emplace_back(nullptr); + continue; + } + + fragments.push_back( + sourcemeta::core::jsonld_canonicalize_fragment(*candidate.value)); + if (candidate.mark == nullptr && + fragment_has_null_entry(fragments.back())) { + return facet_error(location, sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants entry can only be null inside " + "an overriding schema object", + *candidate.origin); + } + } + + for (std::size_t index = 0; index < candidates.size(); index += 1) { + const auto &candidate{candidates[index]}; + const auto &fragment{fragments[index]}; + if (fragment.is_null()) { + continue; + } + + bool contributed{false}; + for (const auto &entry : fragment.as_object()) { + if (entry.second.is_null() || + constants_key_removed(candidates, fragments, candidate, + entry.first)) { + continue; + } + + contributed = + merge_constants_entry(facts.constants, entry.first, entry.second) || + contributed; + } + + if (contributed) { + facts.constants_origin = candidate.origin; + } + } + + return std::nullopt; +} + // The x-jsonld-* keyword names, pre-hashed for the first-pass dispatch using namespace std::string_view_literals; const auto HASH_ID{sourcemeta::core::JSON::Object::hash("x-jsonld-id"sv)}; @@ -717,6 +926,33 @@ const auto HASH_CONTAINER{ const auto HASH_SELF{sourcemeta::core::JSON::Object::hash("x-jsonld-self"sv)}; const auto HASH_OVERRIDE{ sourcemeta::core::JSON::Object::hash("x-jsonld-override"sv)}; +const auto HASH_VALUE{sourcemeta::core::JSON::Object::hash("x-jsonld-value"sv)}; +const auto HASH_CONSTANTS{ + sourcemeta::core::JSON::Object::hash("x-jsonld-constants"sv)}; + +// Whether an x-jsonld-override mark shares its schema object with the value +// predicate at the given location. Only runs when a value predicate needs +// cross-object retargeting consent, so the linear scan never costs the +// common paths anything +auto value_mark_present(const sourcemeta::blaze::SimpleOutput &output, + const sourcemeta::core::WeakPointer &location, + const sourcemeta::core::WeakPointer &value_path) + -> bool { + for (const auto &entry : output.annotations()) { + if (entry.evaluate_path.empty() || entry.instance_location != location) { + continue; + } + + const auto &keyword{entry.evaluate_path.back()}; + if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE) && + entry.value.is_boolean() && entry.value.to_boolean() && + same_schema_object(entry.evaluate_path, value_path)) { + return true; + } + } + + return false; +} // Gather an already-validated annotation into its location's pending // candidates for the override-aware slow path @@ -752,6 +988,10 @@ auto collect(const sourcemeta::blaze::SimpleOutput::AnnotationEntry &entry, pending.containers.push_back(candidate); } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { pending.selves.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + pending.values.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-constants", HASH_CONSTANTS)) { + pending.constants.push_back(candidate); } } @@ -789,8 +1029,10 @@ auto resolve(const sourcemeta::core::JSON &instance, // A mark only matters when it shadows a diverging value or licenses a // null or a false, and each of those demotes the location on its own, - // so the slow path recollects the mark whenever it can act. A future - // keyword whose mark changes agreeing resolutions must demote here + // so the slow path recollects the mark whenever it can act. The one + // exception is retargeting consent, where a mark reshapes an agreeing + // resolution, which the final pass resolves by scanning the collected + // annotations only when a value predicate actually needs consent } else if (keyword.property_equals("x-jsonld-id", HASH_ID) || keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { const bool reverse{ @@ -860,6 +1102,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.datatype.has_value()) { facts.datatype = text; facts.datatype_origin = origin; + facts.datatype_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { @@ -883,6 +1126,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.language.has_value()) { facts.language = text; facts.language_origin = origin; + facts.language_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { @@ -903,6 +1147,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.direction.has_value()) { facts.direction = direction; facts.direction_origin = origin; + facts.direction_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-json", HASH_JSON) || @@ -973,6 +1218,52 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.self.has_value()) { facts.self = text; facts.self_origin = origin; + facts.self_path = &entry.evaluate_path; + } + } + } else if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + if (!value.is_null() && !is_iri_value(value)) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "The value of x-jsonld-value must be an absolute IRI", *origin); + } + + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + const auto &text{value.to_string()}; + if (facts.value.has_value() && facts.value.value() != text) { + demote(accumulator, dirty, instance_location); + } else if (!facts.value.has_value()) { + facts.value = text; + facts.value_origin = origin; + facts.value_path = &entry.evaluate_path; + } + } + } else if (keyword.property_equals("x-jsonld-constants", HASH_CONSTANTS)) { + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else { + auto canonical{ + canonicalize_constants(value, instance_location, *origin)}; + if (std::holds_alternative( + canonical)) { + return std::get( + std::move(canonical)); + } + + const auto &fragment{std::get(canonical)}; + + // Whether a null entry is a licensed tombstone depends on override + // marks that only the slow path can consult + if (fragment_has_null_entry(fragment)) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + if (merge_constants_fragment(facts.constants, fragment)) { + facts.constants_origin = origin; + } } } } @@ -1002,6 +1293,8 @@ auto resolve(const sourcemeta::core::JSON &instance, prepare(entry.graphs, entry.marks); prepare(entry.containers, entry.marks); prepare(entry.selves, entry.marks); + prepare(entry.values, entry.marks); + prepare(entry.constants, entry.marks); std::erase_if(entry.jsons, false_boolean); std::erase_if(entry.graphs, false_boolean); @@ -1024,6 +1317,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (datatype.winner != nullptr) { facts.datatype = datatype.winner->value->to_string(); facts.datatype_origin = datatype.winner->origin; + facts.datatype_path = datatype.winner->path; } const auto language{elect(entry.languages, key_exact)}; @@ -1035,6 +1329,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (language.winner != nullptr) { facts.language = language.winner->value->to_string(); facts.language_origin = language.winner->origin; + facts.language_path = language.winner->path; } const auto direction{elect(entry.directions, key_exact)}; @@ -1046,6 +1341,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (direction.winner != nullptr) { facts.direction = parse_direction(*direction.winner->value); facts.direction_origin = direction.winner->origin; + facts.direction_path = direction.winner->path; } const auto json{elect(entry.jsons, key_boolean)}; @@ -1090,6 +1386,25 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (self.winner != nullptr) { facts.self = self.winner->value->to_string(); facts.self_origin = self.winner->origin; + facts.self_path = self.winner->path; + } + + const auto value_predicate{elect(entry.values, key_exact)}; + if (value_predicate.conflict) { + return conflict_error( + location, sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be assigned more than one value", + value_predicate); + } else if (value_predicate.winner != nullptr) { + facts.value = value_predicate.winner->value->to_string(); + facts.value_origin = value_predicate.winner->origin; + facts.value_path = value_predicate.winner->path; + facts.value_marked = value_predicate.winner->mark != nullptr; + } + + if (auto error{resolve_constants(location, entry.constants, facts)}; + error.has_value()) { + return std::move(error).value(); } accumulator.emplace(location, std::move(facts)); @@ -1131,7 +1446,8 @@ auto resolve(const sourcemeta::core::JSON &instance, offender.datatype_origin, offender.language_origin, offender.direction_origin, offender.container_origin, offender.self_origin, offender.json_origin, - offender.graph_origin})); + offender.graph_origin, offender.value_origin, + offender.constants_origin})); } } } @@ -1162,7 +1478,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.container.has_value()) { if (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.json || facts.self.has_value()) { + facts.json || facts.self.has_value() || facts.value.has_value() || + facts.constants.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD container can only be combined with " @@ -1171,7 +1488,8 @@ auto resolve(const sourcemeta::core::JSON &instance, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, facts.direction_origin, facts.json_origin, - facts.self_origin})); + facts.self_origin, facts.value_origin, + facts.constants_origin})); } if (const auto error{ @@ -1187,7 +1505,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.json && (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.self.has_value())) { + facts.self.has_value() || facts.value.has_value() || + facts.constants.has_value())) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::JSON, "A JSON-LD JSON literal can only be combined with " @@ -1195,15 +1514,98 @@ auto resolve(const sourcemeta::core::JSON &instance, *facts.json_origin, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, - facts.direction_origin, facts.self_origin})); + facts.direction_origin, facts.self_origin, + facts.value_origin, facts.constants_origin})); + } + + // A value predicate promotes its scalar into a node that carries the + // scalar under that predicate, so it needs a scalar to carry, its graph + // pairing is reserved, and reshaping another facet of the location + // requires the consent of shared authorship or an overriding enclosure + if (facts.value.has_value()) { + if (!facts.value_marked && + ((facts.self.has_value() && + !same_schema_object(*facts.value_path, *facts.self_path)) || + (facts.datatype.has_value() && + !same_schema_object(*facts.value_path, *facts.datatype_path)) || + (facts.language.has_value() && + !same_schema_object(*facts.value_path, *facts.language_path)) || + (facts.direction.has_value() && + !same_schema_object(*facts.value_path, *facts.direction_path)))) { + facts.value_marked = + value_mark_present(output, pointer, *facts.value_path); + } + + if (facts.graph) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be combined with " + "a graph flag", + *facts.value_origin, *facts.graph_origin); + } + + if (value.is_object() || value.is_array()) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate can only be assigned to " + "a scalar value", + *facts.value_origin); + } + + if (facts.self.has_value() && + !retargeting_consented(facts, *facts.self_path)) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot fuse with a self " + "identity from an unrelated schema object", + *facts.value_origin, *facts.self_origin); + } + + if (facts.datatype.has_value() && + !retargeting_consented(facts, *facts.datatype_path)) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a datatype " + "from an unrelated schema object", + *facts.value_origin, *facts.datatype_origin); + } + + if (facts.language.has_value() && + !retargeting_consented(facts, *facts.language_path)) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a language " + "from an unrelated schema object", + *facts.value_origin, *facts.language_origin); + } + + if (facts.direction.has_value() && + !retargeting_consented(facts, *facts.direction_path)) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a direction " + "from an unrelated schema object", + *facts.value_origin, *facts.direction_origin); + } + } + + // A constants fragment merges into a node, so its location must + // materialize as one + if (facts.constants.has_value() && !value.is_object() && + !facts.value.has_value() && !facts.self.has_value()) { + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants fragment requires an object " + "value, a value predicate, or a self identity", + *facts.constants_origin); } // A self identity mints an @id, promoting a scalar to a reference and // giving an object its identifier. It describes a node, so it excludes the // literal facets and cannot apply to an array collection if (facts.self.has_value()) { - if (facts.datatype.has_value() || facts.language.has_value() || - facts.direction.has_value()) { + if (!facts.value.has_value() && + (facts.datatype.has_value() || facts.language.has_value() || + facts.direction.has_value())) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, "A JSON-LD self identity cannot carry a datatype, " @@ -1268,6 +1670,7 @@ auto resolve(const sourcemeta::core::JSON &instance, const bool points_to_node{ !facts.json && !facts.container.has_value() && (value.is_object() || facts.self.has_value() || + facts.value.has_value() || (value.is_array() && array_of_nodes(accumulator, pointer, value)))}; if (!points_to_node) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Predicate, @@ -1298,16 +1701,31 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (facts.container.has_value()) { descriptor.value = sourcemeta::core::JSONLDCollection{ .container = facts.container.value()}; + } else if (facts.value.has_value()) { + descriptor.value = sourcemeta::core::JSONLDPromotion{ + .id = std::move(identifier), + .types = std::move(facts.types), + .value = std::move(facts.value).value(), + .literal = + sourcemeta::core::JSONLDLiteral{ + .datatype = std::move(facts.datatype), + .language = std::move(facts.language), + .direction = facts.direction, + .json = false}, + .constants = take_constants(facts)}; } else if (value.is_object()) { descriptor.value = sourcemeta::core::JSONLDNode{.id = std::move(identifier), .types = std::move(facts.types), - .graph = facts.graph}; + .graph = facts.graph, + .constants = take_constants(facts)}; } else if (value.is_array()) { descriptor.value = sourcemeta::core::JSONLDCollection{}; } else if (identifier.has_value()) { - descriptor.value = sourcemeta::core::JSONLDReference{ - .id = std::move(identifier.value()), .types = std::move(facts.types)}; + descriptor.value = + sourcemeta::core::JSONLDReference{.id = std::move(identifier.value()), + .types = std::move(facts.types), + .constants = take_constants(facts)}; } else { descriptor.value = sourcemeta::core::JSONLDLiteral{.datatype = std::move(facts.datatype), diff --git a/test/output/output_jsonld_test.cc b/test/output/output_jsonld_test.cc index 789918d56..2aad9f67e 100644 --- a/test/output/output_jsonld_test.cc +++ b/test/output/output_jsonld_test.cc @@ -13210,3 +13210,2276 @@ TEST(JSONLD_origins_language_container_member_annotation) { "#/properties/x/x-jsonld-container", "#/properties/x/properties/en/x-jsonld-datatype"); } + +TEST(JSONLD_value_promotes_number_to_quantity_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "height": { + "type": "number", + "x-jsonld-id": "https://schema.org/height", + "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "height": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/height": [ + { + "@type": [ "http://qudt.org/schema/qudt/Quantity" ], + "http://qudt.org/schema/qudt/value": [ { "@value": 1.85 } ], + "http://qudt.org/schema/qudt/hasUnit": [ + { "@id": "http://qudt.org/vocab/unit/M" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_promotion_through_reference) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "metre": { + "type": "number", + "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + }, + "properties": { + "height": { + "x-jsonld-id": "https://schema.org/height", + "$ref": "#/$defs/metre" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "height": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/height": [ + { + "@type": [ "http://qudt.org/schema/qudt/Quantity" ], + "http://qudt.org/schema/qudt/value": [ { "@value": 1.85 } ], + "http://qudt.org/schema/qudt/hasUnit": [ + { "@id": "http://qudt.org/vocab/unit/M" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_alone_is_a_blank_node_with_one_edge) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": "v" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_boolean_scalar) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": true })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": true } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_with_self_is_a_named_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "string", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-self": "https://example.com/{this}", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "@id": "https://example.com/v", + "https://example.com/carries": [ { "@value": "v" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_with_datatype_in_same_schema_object) { + 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://schema.org/x", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#decimal" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "http://qudt.org/schema/qudt/value": [ + { + "@value": "1.85", + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_with_language_in_same_schema_object) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "string", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-language": "fr" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": "salut" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ + { "@value": "salut", "@language": "fr" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_with_direction_in_same_schema_object) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "string", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-direction": "rtl" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ + { "@value": "v", "@direction": "rtl" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_with_datatype_consented_by_overriding_enclosure) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "typed": { + "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#decimal" + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "$ref": "#/$defs/typed" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "http://qudt.org/schema/qudt/value": [ + { + "@value": "1.85", + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_on_null_is_dropped) { + 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://schema.org/x", + "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": null })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_anonymous_root_scalar_is_omitted) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "number", + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON(1.85)JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_named_root_scalar_is_kept) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "x-jsonld-self": "https://example.com/quantity/{this}", + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON("m1")JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@id": "https://example.com/quantity/m1", + "http://qudt.org/schema/qudt/value": [ { "@value": "m1" } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_array_elements_promote_individually) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "array", + "x-jsonld-id": "https://schema.org/x", + "items": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1, 2 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { "http://qudt.org/schema/qudt/value": [ { "@value": 1 } ] }, + { "http://qudt.org/schema/qudt/value": [ { "@value": 2 } ] } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_array_elements_promote_inside_list_container) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "array", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-container": "@list", + "items": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1, 2 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "@list": [ + { "http://qudt.org/schema/qudt/value": [ { "@value": 1 } ] }, + { "http://qudt.org/schema/qudt/value": [ { "@value": 2 } ] } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_reverse_edge_to_promoted_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "x-jsonld-reverse": "https://schema.org/hasPart", + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 5 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@reverse": { + "https://schema.org/hasPart": [ + { + "http://qudt.org/schema/qudt/value": [ { "@value": 5 } ] + } + ] + } + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_reverse_edge_to_array_of_promoted_nodes) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "array", + "x-jsonld-reverse": "https://schema.org/hasPart", + "items": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 5 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "@reverse": { + "https://schema.org/hasPart": [ + { + "http://qudt.org/schema/qudt/value": [ { "@value": 5 } ] + } + ] + } + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_types_merge_from_multiple_branches) { + 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://schema.org/x", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "allOf": [ + { "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity" }, + { "x-jsonld-type": "https://example.com/Measurement" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 5 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "@type": [ + "http://qudt.org/schema/qudt/Quantity", + "https://example.com/Measurement" + ], + "http://qudt.org/schema/qudt/value": [ { "@value": 5 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_without_edge_or_identity_is_omitted) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 5 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_on_object_node) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "object", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-constants": { + "https://example.com/status": "REC" + }, + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" } + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": { "name": "Ada" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://schema.org/name": [ { "@value": "Ada" } ], + "https://example.com/status": [ { "@value": "REC" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_on_named_reference) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "string", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-self": "https://example.com/{this}", + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "@id": "https://example.com/v", + "https://example.com/status": [ { "@value": "REC" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_literal_forms) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/text": "plain", + "https://example.com/count": 2, + "https://example.com/flag": true, + "https://example.com/factor": { + "@value": 0.5, + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + }, + "https://example.com/label": { + "@value": "metre", + "@language": "en" + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/count": [ { "@value": 2 } ], + "https://example.com/factor": [ + { + "@value": "0.5", + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + } + ], + "https://example.com/flag": [ { "@value": true } ], + "https://example.com/label": [ + { "@value": "metre", "@language": "en" } + ], + "https://example.com/text": [ { "@value": "plain" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_array_entry_multiple_terms) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/seeAlso": [ + { "@id": "https://example.com/a" }, + { "@id": "https://example.com/b" } + ] + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/seeAlso": [ + { "@id": "https://example.com/a" }, + { "@id": "https://example.com/b" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_maps_union_by_key_across_branches) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { + "x-jsonld-constants": { + "https://example.com/base": { "@id": "https://example.com/a" } + } + }, + { + "x-jsonld-constants": { + "https://example.com/derived": { "@id": "https://example.com/b" } + } + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/base": [ { "@id": "https://example.com/a" } ], + "https://example.com/derived": [ { "@id": "https://example.com/b" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_same_key_terms_fan_out) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/a" } + } + }, + { + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/b" } + } + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/unit": [ + { "@id": "https://example.com/a" }, + { "@id": "https://example.com/b" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_identical_terms_dedupe_across_branches) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/a" } + } + }, + { + "x-jsonld-constants": { + "https://example.com/unit": [ { "@id": "https://example.com/a" } ] + } + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/unit": [ { "@id": "https://example.com/a" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_scalar_sugar_dedupes_with_value_object) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + }, + { + "x-jsonld-constants": { + "https://example.com/status": { "@value": "REC" } + } + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/status": [ { "@value": "REC" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_diamond_reference_dedupes) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "library": { + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/a" } + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { "$ref": "#/$defs/library" }, + { "$ref": "#/$defs/library" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/unit": [ { "@id": "https://example.com/a" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_cross_numeric_terms_dedupe) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "allOf": [ + { + "x-jsonld-constants": { "https://example.com/count": 1 } + }, + { + "x-jsonld-constants": { "https://example.com/count": 1.0 } + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 2 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 2 } ], + "https://example.com/count": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_empty_map_is_a_noop) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": {} + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_empty_array_entry_is_a_noop) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/seeAlso": [] + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_key_collides_with_value_predicate) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/carries": 99 + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 }, { "@value": 99 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_whole_keyword_null_without_override_is_ignored) { + 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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": null + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_whole_keyword_tombstone_clears_referenced_constants) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "library": { + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/a" } + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-constants": null, + "$ref": "#/$defs/library" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_unit_swap_split_spelling) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "metre": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/FT" + } + }, + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": null + }, + "$ref": "#/$defs/metre" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "http://qudt.org/schema/qudt/value": [ { "@value": 1 } ], + "http://qudt.org/schema/qudt/hasUnit": [ + { "@id": "http://qudt.org/vocab/unit/FT" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_per_key_tombstone_keeps_other_keys) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "library": { + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/unit": { "@id": "https://example.com/a" }, + "https://example.com/status": "REC" + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-constants": { + "https://example.com/unit": null + }, + "$ref": "#/$defs/library" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ], + "https://example.com/status": [ { "@value": "REC" } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_triple_tombstone_restores_plain_literal) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "metre": { + "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity", + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-value": null, + "x-jsonld-type": null, + "x-jsonld-constants": null, + "$ref": "#/$defs/metre" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ { "@value": 1.85 } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_on_graph_object_land_on_inner_subject) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "object", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-graph": true, + "x-jsonld-constants": { + "https://example.com/status": "REC" + }, + "properties": { + "name": { "x-jsonld-id": "https://schema.org/name" } + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": { "name": "Ada" } })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "@graph": [ + { + "https://schema.org/name": [ { "@value": "Ada" } ], + "https://example.com/status": [ { "@value": "REC" } ] + } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_non_string_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-value": 1 } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "The value of x-jsonld-value must be an absolute IRI", + "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_value_relative_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-value": "carries" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "The value of x-jsonld-value must be an absolute IRI", + "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_value_empty_string_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-value": "" } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "The value of x-jsonld-value must be an absolute IRI", + "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_value_rdf_type_predicate_is_emitted_as_written) { + 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://schema.org/x", + "x-jsonld-value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": [ + { "@value": "v" } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_conflict_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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "https://example.com/a" }, + { "x-jsonld-value": "https://example.com/b" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be assigned more than one value", + "#/properties/x/allOf/0/x-jsonld-value", + "#/properties/x/allOf/1/x-jsonld-value"); +} + +TEST(JSONLD_value_identical_declarations_dedupe) { + 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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "https://example.com/carries" }, + { "x-jsonld-value": "https://example.com/carries" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "https://example.com/carries": [ { "@value": 1 } ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_value_on_object_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": {} })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate can only be assigned to a scalar value", + "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_value_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": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate can only be assigned to a scalar value", + "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_value_with_graph_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-graph": true + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be combined with a graph flag", + "#/properties/x/x-jsonld-value", "#/properties/x/x-jsonld-graph"); +} + +TEST(JSONLD_value_with_json_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-json": true + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::JSON, + "A JSON-LD JSON literal can only be combined with predicate " + "annotations", + "#/properties/x/x-jsonld-json", "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_constants_with_json_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://schema.org/x", + "x-jsonld-json": true, + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::JSON, + "A JSON-LD JSON literal can only be combined with predicate " + "annotations", + "#/properties/x/x-jsonld-json", "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_value_with_container_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://schema.org/x", + "x-jsonld-container": "@list", + "x-jsonld-value": "https://example.com/carries" + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD container can only be combined with predicate annotations", + "#/properties/x/x-jsonld-container", "#/properties/x/x-jsonld-value"); +} + +TEST(JSONLD_constants_with_container_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://schema.org/x", + "x-jsonld-container": "@list", + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD container can only be combined with predicate annotations", + "#/properties/x/x-jsonld-container", "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_alone_on_scalar_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://schema.org/x", + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants fragment requires an object value, a value " + "predicate, or a self identity", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_alone_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": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-constants": { + "https://example.com/status": "REC" + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants fragment requires an object value, a value " + "predicate, or a self identity", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_value_lone_tombstone_strands_constants) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "metre": { + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-constants": { + "http://qudt.org/schema/qudt/hasUnit": { + "@id": "http://qudt.org/vocab/unit/M" + } + } + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-value": null, + "$ref": "#/$defs/metre" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants fragment requires an object value, a value " + "predicate, or a self identity", + "#/$defs/metre/x-jsonld-constants"); +} + +TEST(JSONLD_value_lone_tombstone_strands_type) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "metre": { + "x-jsonld-type": "http://qudt.org/schema/qudt/Quantity", + "x-jsonld-value": "http://qudt.org/schema/qudt/value" + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-value": null, + "$ref": "#/$defs/metre" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Type, + "A JSON-LD type can only be assigned to an object value", + "#/$defs/metre/x-jsonld-type"); +} + +TEST(JSONLD_constants_unmarked_null_entry_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/unit": null + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants entry can only be null inside an overriding " + "schema object", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_value_cross_path_datatype_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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "http://qudt.org/schema/qudt/value" }, + { "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#decimal" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a datatype from an unrelated " + "schema object", + "#/properties/x/allOf/0/x-jsonld-value", + "#/properties/x/allOf/1/x-jsonld-datatype"); +} + +TEST(JSONLD_value_cross_path_language_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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "https://example.com/carries" }, + { "x-jsonld-language": "en" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a language from an unrelated " + "schema object", + "#/properties/x/allOf/0/x-jsonld-value", + "#/properties/x/allOf/1/x-jsonld-language"); +} + +TEST(JSONLD_value_cross_path_direction_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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "https://example.com/carries" }, + { "x-jsonld-direction": "rtl" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a direction from an unrelated " + "schema object", + "#/properties/x/allOf/0/x-jsonld-value", + "#/properties/x/allOf/1/x-jsonld-direction"); +} + +TEST(JSONLD_value_cross_path_self_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://schema.org/x", + "allOf": [ + { "x-jsonld-value": "https://example.com/carries" }, + { "x-jsonld-self": "https://example.com/{this}" } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": "v" })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot fuse with a self identity from an " + "unrelated schema object", + "#/properties/x/allOf/0/x-jsonld-value", + "#/properties/x/allOf/1/x-jsonld-self"); +} + +TEST(JSONLD_value_language_container_member_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": { + "type": "object", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-container": "@language", + "properties": { + "en": { "x-jsonld-value": "https://example.com/carries" } + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": { "en": "v" } })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR_WITH_CONFLICT( + schema, instance, "/x/en", sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD language container member cannot carry a JSON-LD annotation", + "#/properties/x/x-jsonld-container", + "#/properties/x/properties/en/x-jsonld-value"); +} + +TEST(JSONLD_constants_non_object_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": "not a fragment" + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR(schema, instance, "/x", + sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment must be an object", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_array_value_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": [] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR(schema, instance, "/x", + sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment must be an object", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_type_keyword_key_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "@type": "http://qudt.org/schema/qudt/Quantity" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment cannot declare node types", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_rdf_type_predicate_key_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { + "@id": "http://qudt.org/schema/qudt/Quantity" + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment cannot declare node types", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_id_keyword_key_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "@id": "https://example.com/a" + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment cannot declare a node identifier", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_keyword_key_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "@context": {} + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment key cannot be a keyword", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_relative_key_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "unit": { "@id": "https://example.com/a" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment key must be an absolute IRI", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_node_reference_with_properties_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/seeAlso": { + "@id": "https://example.com/a", + "https://example.com/comment": "x" + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A node reference can only carry an identifier", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_blank_node_reference_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/seeAlso": { "@id": "_:b0" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A node reference identifier must be an absolute IRI", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_list_term_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/list": { "@list": [ 1, 2 ] } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment term must be a scalar, a node reference, or a " + "value object", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_null_array_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": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/seeAlso": [ null ] + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A constants fragment term must be a scalar, a node reference, or a " + "value object", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_null_value_object_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/status": { "@value": null } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object value must be a non-null scalar", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_type_and_language_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/label": { + "@value": "metre", + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@language": "en" + } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object cannot combine a type and a language", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_language_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": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/label": { "@value": 1, "@language": "en" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object language requires a string value", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_non_canonical_language_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/label": { "@value": "metre", "@language": "en-us" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object language must be a canonical BCP 47 language tag", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_json_literal_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", + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/data": { "@value": "x", "@type": "@json" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object type cannot be the JSON literal type", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_constants_value_object_index_member_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://schema.org/x", + "x-jsonld-value": "https://example.com/carries", + "x-jsonld-constants": { + "https://example.com/status": { "@value": "x", "@index": "a" } + } + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + EXPECT_JSON_LD_RESOLUTION_ERROR( + schema, instance, "/x", sourcemeta::blaze::JSONLDFacet::Constants, + "A value object can only carry a value, a type, and a language", + "#/properties/x/x-jsonld-constants"); +} + +TEST(JSONLD_value_retargeting_consent_survives_demotion) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "typed": { + "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#decimal" + } + }, + "properties": { + "x": { + "x-jsonld-id": "https://schema.org/x", + "allOf": [ + { + "x-jsonld-override": true, + "x-jsonld-value": "http://qudt.org/schema/qudt/value", + "x-jsonld-graph": null, + "$ref": "#/$defs/typed" + } + ] + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1.85 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { + "http://qudt.org/schema/qudt/value": [ + { + "@value": "1.85", + "@type": "http://www.w3.org/2001/XMLSchema#decimal" + } + ] + } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} From c3515ea85bba744745b452f531e4a6b71a80800f Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 2 Aug 2026 21:14:06 -0300 Subject: [PATCH 2/3] More Signed-off-by: Juan Cruz Viotti --- src/output/output_jsonld.cc | 315 ++++++++++++++++++++++-------------- 1 file changed, 194 insertions(+), 121 deletions(-) diff --git a/src/output/output_jsonld.cc b/src/output/output_jsonld.cc index fee16cf43..fae9125b6 100644 --- a/src/output/output_jsonld.cc +++ b/src/output/output_jsonld.cc @@ -10,6 +10,7 @@ #include // assert #include // std::ref, std::reference_wrapper #include // std::initializer_list +#include // std::unique_ptr, std::make_unique #include // std::optional #include // std::string #include // std::string_view @@ -29,6 +30,15 @@ namespace { // entries, so carrying them costs no allocation. The value predicate and the // literal facets additionally remember their evaluate paths, as retargeting // consent compares schema object placement +// The promotion facts, boxed behind one pointer on Facts so that schemas +// without promotion keywords never pay for their size in the accumulator +struct PromotionFacts { + std::optional value; + std::optional constants; + const std::string *value_origin{nullptr}; + const std::string *constants_origin{nullptr}; +}; + struct Facts { std::vector edges; std::vector types; @@ -37,11 +47,9 @@ struct Facts { std::optional direction; std::optional container; std::optional self; - std::optional value; - std::optional constants; + std::unique_ptr promotion; bool json{false}; bool graph{false}; - bool value_marked{false}; const std::string *edges_origin{nullptr}; const std::string *reverse_origin{nullptr}; const std::string *types_origin{nullptr}; @@ -52,15 +60,36 @@ struct Facts { const std::string *self_origin{nullptr}; const std::string *json_origin{nullptr}; const std::string *graph_origin{nullptr}; - const std::string *value_origin{nullptr}; - const std::string *constants_origin{nullptr}; - const sourcemeta::core::WeakPointer *value_path{nullptr}; - const sourcemeta::core::WeakPointer *self_path{nullptr}; - const sourcemeta::core::WeakPointer *datatype_path{nullptr}; - const sourcemeta::core::WeakPointer *language_path{nullptr}; - const sourcemeta::core::WeakPointer *direction_path{nullptr}; }; +// The promotion facts of a location, materialized on first use +auto promotion_facts(Facts &facts) -> PromotionFacts & { + if (facts.promotion == nullptr) { + facts.promotion = std::make_unique(); + } + + return *facts.promotion; +} + +// Whether the location is promoted by a value predicate +auto promoted(const Facts &facts) -> bool { + return facts.promotion != nullptr && facts.promotion->value.has_value(); +} + +// Whether the location carries a constants fragment +auto with_constants(const Facts &facts) -> bool { + return facts.promotion != nullptr && facts.promotion->constants.has_value(); +} + +auto promotion_value_origin(const Facts &facts) -> const std::string * { + return facts.promotion == nullptr ? nullptr : facts.promotion->value_origin; +} + +auto promotion_constants_origin(const Facts &facts) -> const std::string * { + return facts.promotion == nullptr ? nullptr + : facts.promotion->constants_origin; +} + using Accumulator = std::unordered_map; @@ -157,8 +186,8 @@ auto merge_constants_fragment( // The constants map a node-producing descriptor takes: the accumulated // canonical fragment, or the empty map the descriptor kinds default to auto take_constants(Facts &facts) -> sourcemeta::core::JSON { - if (facts.constants.has_value()) { - return std::move(facts.constants).value(); + if (with_constants(facts)) { + return std::move(facts.promotion->constants).value(); } return sourcemeta::core::JSON::make_object(); @@ -251,6 +280,11 @@ auto parse_direction(const sourcemeta::core::JSON &value) return std::nullopt; } +auto direction_text(const sourcemeta::core::JSONLDDirection direction) + -> sourcemeta::core::JSON::String { + return direction == sourcemeta::core::JSONLDDirection::LTR ? "ltr" : "rtl"; +} + auto parse_container(const sourcemeta::core::JSON &value) -> std::optional { if (!value.is_string()) { @@ -342,8 +376,7 @@ auto placement_error(const sourcemeta::core::WeakPointer &pointer, if (!value.is_object()) { // A self identity promotes a scalar to a reference, which carries its own // types, so a type is only misplaced on a scalar that has no self identity - if (!facts.types.empty() && !facts.self.has_value() && - !facts.value.has_value()) { + if (!facts.types.empty() && !facts.self.has_value() && !promoted(facts)) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Type, "A JSON-LD type can only be assigned to an object value", @@ -525,7 +558,7 @@ auto array_of_nodes(const Accumulator &accumulator, const auto element_facts{accumulator.find(element_pointer)}; if (element_facts == accumulator.cend() || (!element_facts->second.self.has_value() && - !element_facts->second.value.has_value())) { + !promoted(element_facts->second))) { return false; } } @@ -550,15 +583,37 @@ auto same_schema_object(const sourcemeta::core::WeakPointer &left, return left.size() == right.size() && right.starts_with_initial(left); } -// Whether the value predicate may reshape another facet of its location: -// either both were declared in one schema object, one author's intentional -// spelling, or the value predicate is override-marked and its schema object -// encloses the other facet's, a composer restructuring what it composes -auto retargeting_consented(const Facts &facts, +// The declaration paths that can consent to retargeting at one location: +// the paths declaring the resolved value predicate and the paths of the +// override marks. Only promotion locations with a cross-facet fusion ever +// collect them, so the scans never cost the common paths anything +struct ConsentPaths { + std::vector values; + std::vector marks; +}; + +// Whether a facet declaration is consented to follow the value predicate +// into the promoted node: either it shares a schema object with a value +// predicate declaration, one author's intentional spelling, or a value +// predicate declaration is override-marked and its schema object encloses +// the facet's, a composer restructuring what it composes +auto declaration_consented(const ConsentPaths &consent, const sourcemeta::core::WeakPointer &facet_path) -> bool { - return same_schema_object(*facts.value_path, facet_path) || - (facts.value_marked && encloses(*facts.value_path, facet_path)); + for (const auto *value_path : consent.values) { + if (same_schema_object(*value_path, facet_path)) { + return true; + } + + for (const auto *mark : consent.marks) { + if (same_schema_object(*mark, *value_path) && + encloses(*value_path, facet_path)) { + return true; + } + } + } + + return false; } // An x-jsonld-override annotation that evaluated to true at a location, @@ -894,13 +949,13 @@ auto resolve_constants(const sourcemeta::core::WeakPointer &location, continue; } - contributed = - merge_constants_entry(facts.constants, entry.first, entry.second) || - contributed; + contributed = merge_constants_entry(promotion_facts(facts).constants, + entry.first, entry.second) || + contributed; } if (contributed) { - facts.constants_origin = candidate.origin; + promotion_facts(facts).constants_origin = candidate.origin; } } @@ -930,23 +985,48 @@ const auto HASH_VALUE{sourcemeta::core::JSON::Object::hash("x-jsonld-value"sv)}; const auto HASH_CONSTANTS{ sourcemeta::core::JSON::Object::hash("x-jsonld-constants"sv)}; -// Whether an x-jsonld-override mark shares its schema object with the value -// predicate at the given location. Only runs when a value predicate needs -// cross-object retargeting consent, so the linear scan never costs the -// common paths anything -auto value_mark_present(const sourcemeta::blaze::SimpleOutput &output, - const sourcemeta::core::WeakPointer &location, - const sourcemeta::core::WeakPointer &value_path) - -> bool { +// Collect the consent paths of a promoted location from the collected +// annotations +auto collect_consent_paths( + const sourcemeta::blaze::SimpleOutput &output, + const sourcemeta::core::WeakPointer &location, + const sourcemeta::core::JSON::String &value_predicate) -> ConsentPaths { + ConsentPaths result; + for (const auto &entry : output.annotations()) { + if (entry.evaluate_path.empty() || entry.instance_location != location) { + continue; + } + + const auto &keyword{entry.evaluate_path.back()}; + if (keyword.property_equals("x-jsonld-value", HASH_VALUE) && + entry.value.is_string() && entry.value.to_string() == value_predicate) { + result.values.push_back(&entry.evaluate_path); + } else if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE) && + entry.value.is_boolean() && entry.value.to_boolean()) { + result.marks.push_back(&entry.evaluate_path); + } + } + + return result; +} + +// Whether any declaration of the given facet value at the location is +// consented to follow the value predicate into the promoted node +auto facet_retargeting_consented( + const sourcemeta::blaze::SimpleOutput &output, + const sourcemeta::core::WeakPointer &location, const ConsentPaths &consent, + const sourcemeta::core::JSON::StringView keyword_name, + const sourcemeta::core::JSON::Object::hash_type keyword_hash, + const sourcemeta::core::JSON &expected) -> bool { for (const auto &entry : output.annotations()) { if (entry.evaluate_path.empty() || entry.instance_location != location) { continue; } const auto &keyword{entry.evaluate_path.back()}; - if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE) && - entry.value.is_boolean() && entry.value.to_boolean() && - same_schema_object(entry.evaluate_path, value_path)) { + if (keyword.property_equals(keyword_name, keyword_hash) && + entry.value == expected && + declaration_consented(consent, entry.evaluate_path)) { return true; } } @@ -1102,7 +1182,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.datatype.has_value()) { facts.datatype = text; facts.datatype_origin = origin; - facts.datatype_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { @@ -1126,7 +1205,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.language.has_value()) { facts.language = text; facts.language_origin = origin; - facts.language_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { @@ -1147,7 +1225,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.direction.has_value()) { facts.direction = direction; facts.direction_origin = origin; - facts.direction_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-json", HASH_JSON) || @@ -1218,7 +1295,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!facts.self.has_value()) { facts.self = text; facts.self_origin = origin; - facts.self_path = &entry.evaluate_path; } } } else if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { @@ -1233,12 +1309,12 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (!dirty.contains(instance_location)) { auto &facts{accumulator[instance_location]}; const auto &text{value.to_string()}; - if (facts.value.has_value() && facts.value.value() != text) { + auto &promotion{promotion_facts(facts)}; + if (promotion.value.has_value() && promotion.value.value() != text) { demote(accumulator, dirty, instance_location); - } else if (!facts.value.has_value()) { - facts.value = text; - facts.value_origin = origin; - facts.value_path = &entry.evaluate_path; + } else if (!promotion.value.has_value()) { + promotion.value = text; + promotion.value_origin = origin; } } } else if (keyword.property_equals("x-jsonld-constants", HASH_CONSTANTS)) { @@ -1260,9 +1336,9 @@ auto resolve(const sourcemeta::core::JSON &instance, if (fragment_has_null_entry(fragment)) { demote(accumulator, dirty, instance_location); } else if (!dirty.contains(instance_location)) { - auto &facts{accumulator[instance_location]}; - if (merge_constants_fragment(facts.constants, fragment)) { - facts.constants_origin = origin; + auto &promotion{promotion_facts(accumulator[instance_location])}; + if (merge_constants_fragment(promotion.constants, fragment)) { + promotion.constants_origin = origin; } } } @@ -1317,7 +1393,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (datatype.winner != nullptr) { facts.datatype = datatype.winner->value->to_string(); facts.datatype_origin = datatype.winner->origin; - facts.datatype_path = datatype.winner->path; } const auto language{elect(entry.languages, key_exact)}; @@ -1329,7 +1404,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (language.winner != nullptr) { facts.language = language.winner->value->to_string(); facts.language_origin = language.winner->origin; - facts.language_path = language.winner->path; } const auto direction{elect(entry.directions, key_exact)}; @@ -1341,7 +1415,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (direction.winner != nullptr) { facts.direction = parse_direction(*direction.winner->value); facts.direction_origin = direction.winner->origin; - facts.direction_path = direction.winner->path; } const auto json{elect(entry.jsons, key_boolean)}; @@ -1386,7 +1459,6 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (self.winner != nullptr) { facts.self = self.winner->value->to_string(); facts.self_origin = self.winner->origin; - facts.self_path = self.winner->path; } const auto value_predicate{elect(entry.values, key_exact)}; @@ -1396,10 +1468,9 @@ auto resolve(const sourcemeta::core::JSON &instance, "A JSON-LD value predicate cannot be assigned more than one value", value_predicate); } else if (value_predicate.winner != nullptr) { - facts.value = value_predicate.winner->value->to_string(); - facts.value_origin = value_predicate.winner->origin; - facts.value_path = value_predicate.winner->path; - facts.value_marked = value_predicate.winner->mark != nullptr; + auto &promotion{promotion_facts(facts)}; + promotion.value = value_predicate.winner->value->to_string(); + promotion.value_origin = value_predicate.winner->origin; } if (auto error{resolve_constants(location, entry.constants, facts)}; @@ -1446,8 +1517,9 @@ auto resolve(const sourcemeta::core::JSON &instance, offender.datatype_origin, offender.language_origin, offender.direction_origin, offender.container_origin, offender.self_origin, offender.json_origin, - offender.graph_origin, offender.value_origin, - offender.constants_origin})); + offender.graph_origin, + promotion_value_origin(offender), + promotion_constants_origin(offender)})); } } } @@ -1478,8 +1550,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.container.has_value()) { if (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.json || facts.self.has_value() || facts.value.has_value() || - facts.constants.has_value()) { + facts.json || facts.self.has_value() || facts.promotion != nullptr) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD container can only be combined with " @@ -1488,8 +1559,8 @@ auto resolve(const sourcemeta::core::JSON &instance, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, facts.direction_origin, facts.json_origin, - facts.self_origin, facts.value_origin, - facts.constants_origin})); + facts.self_origin, promotion_value_origin(facts), + promotion_constants_origin(facts)})); } if (const auto error{ @@ -1505,8 +1576,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.json && (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.self.has_value() || facts.value.has_value() || - facts.constants.has_value())) { + facts.self.has_value() || facts.promotion != nullptr)) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::JSON, "A JSON-LD JSON literal can only be combined with " @@ -1515,33 +1585,22 @@ auto resolve(const sourcemeta::core::JSON &instance, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, facts.direction_origin, facts.self_origin, - facts.value_origin, facts.constants_origin})); + promotion_value_origin(facts), + promotion_constants_origin(facts)})); } // A value predicate promotes its scalar into a node that carries the // scalar under that predicate, so it needs a scalar to carry, its graph // pairing is reserved, and reshaping another facet of the location // requires the consent of shared authorship or an overriding enclosure - if (facts.value.has_value()) { - if (!facts.value_marked && - ((facts.self.has_value() && - !same_schema_object(*facts.value_path, *facts.self_path)) || - (facts.datatype.has_value() && - !same_schema_object(*facts.value_path, *facts.datatype_path)) || - (facts.language.has_value() && - !same_schema_object(*facts.value_path, *facts.language_path)) || - (facts.direction.has_value() && - !same_schema_object(*facts.value_path, *facts.direction_path)))) { - facts.value_marked = - value_mark_present(output, pointer, *facts.value_path); - } - + if (promoted(facts)) { + const auto &promotion{*facts.promotion}; if (facts.graph) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::ValuePredicate, "A JSON-LD value predicate cannot be combined with " "a graph flag", - *facts.value_origin, *facts.graph_origin); + *promotion.value_origin, *facts.graph_origin); } if (value.is_object() || value.is_array()) { @@ -1549,61 +1608,76 @@ auto resolve(const sourcemeta::core::JSON &instance, sourcemeta::blaze::JSONLDFacet::ValuePredicate, "A JSON-LD value predicate can only be assigned to " "a scalar value", - *facts.value_origin); + *promotion.value_origin); } - if (facts.self.has_value() && - !retargeting_consented(facts, *facts.self_path)) { - return facet_error(pointer, - sourcemeta::blaze::JSONLDFacet::ValuePredicate, - "A JSON-LD value predicate cannot fuse with a self " - "identity from an unrelated schema object", - *facts.value_origin, *facts.self_origin); - } + if (facts.self.has_value() || facts.datatype.has_value() || + facts.language.has_value() || facts.direction.has_value()) { + const auto consent{ + collect_consent_paths(output, pointer, promotion.value.value())}; + + if (facts.self.has_value() && + !facet_retargeting_consented( + output, pointer, consent, "x-jsonld-self", HASH_SELF, + sourcemeta::core::JSON{facts.self.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot fuse with a " + "self identity from an unrelated schema object", + *promotion.value_origin, *facts.self_origin); + } - if (facts.datatype.has_value() && - !retargeting_consented(facts, *facts.datatype_path)) { - return facet_error(pointer, - sourcemeta::blaze::JSONLDFacet::ValuePredicate, - "A JSON-LD value predicate cannot adopt a datatype " - "from an unrelated schema object", - *facts.value_origin, *facts.datatype_origin); - } + if (facts.datatype.has_value() && + !facet_retargeting_consented( + output, pointer, consent, "x-jsonld-datatype", HASH_DATATYPE, + sourcemeta::core::JSON{facts.datatype.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "datatype from an unrelated schema object", + *promotion.value_origin, *facts.datatype_origin); + } - if (facts.language.has_value() && - !retargeting_consented(facts, *facts.language_path)) { - return facet_error(pointer, - sourcemeta::blaze::JSONLDFacet::ValuePredicate, - "A JSON-LD value predicate cannot adopt a language " - "from an unrelated schema object", - *facts.value_origin, *facts.language_origin); - } + if (facts.language.has_value() && + !facet_retargeting_consented( + output, pointer, consent, "x-jsonld-language", HASH_LANGUAGE, + sourcemeta::core::JSON{facts.language.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "language from an unrelated schema object", + *promotion.value_origin, *facts.language_origin); + } - if (facts.direction.has_value() && - !retargeting_consented(facts, *facts.direction_path)) { - return facet_error(pointer, - sourcemeta::blaze::JSONLDFacet::ValuePredicate, - "A JSON-LD value predicate cannot adopt a direction " - "from an unrelated schema object", - *facts.value_origin, *facts.direction_origin); + if (facts.direction.has_value() && + !facet_retargeting_consented(output, pointer, consent, + "x-jsonld-direction", HASH_DIRECTION, + sourcemeta::core::JSON{direction_text( + facts.direction.value())})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "direction from an unrelated schema object", + *promotion.value_origin, *facts.direction_origin); + } } } // A constants fragment merges into a node, so its location must // materialize as one - if (facts.constants.has_value() && !value.is_object() && - !facts.value.has_value() && !facts.self.has_value()) { + if (with_constants(facts) && !value.is_object() && !promoted(facts) && + !facts.self.has_value()) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Constants, "A JSON-LD constants fragment requires an object " "value, a value predicate, or a self identity", - *facts.constants_origin); + *facts.promotion->constants_origin); } // A self identity mints an @id, promoting a scalar to a reference and // giving an object its identifier. It describes a node, so it excludes the // literal facets and cannot apply to an array collection if (facts.self.has_value()) { - if (!facts.value.has_value() && + if (!promoted(facts) && (facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value())) { return facet_error( @@ -1669,8 +1743,7 @@ auto resolve(const sourcemeta::core::JSON &instance, })) { const bool points_to_node{ !facts.json && !facts.container.has_value() && - (value.is_object() || facts.self.has_value() || - facts.value.has_value() || + (value.is_object() || facts.self.has_value() || promoted(facts) || (value.is_array() && array_of_nodes(accumulator, pointer, value)))}; if (!points_to_node) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Predicate, @@ -1701,11 +1774,11 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (facts.container.has_value()) { descriptor.value = sourcemeta::core::JSONLDCollection{ .container = facts.container.value()}; - } else if (facts.value.has_value()) { + } else if (promoted(facts)) { descriptor.value = sourcemeta::core::JSONLDPromotion{ .id = std::move(identifier), .types = std::move(facts.types), - .value = std::move(facts.value).value(), + .value = std::move(facts.promotion->value).value(), .literal = sourcemeta::core::JSONLDLiteral{ .datatype = std::move(facts.datatype), From ea021caa0be3f8890e5c44008bb5e5e2e6fc050e Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 2 Aug 2026 21:33:07 -0300 Subject: [PATCH 3/3] Fix Signed-off-by: Juan Cruz Viotti --- .../v1/2019-09/vocabulary.json | 10 +- .../v1/2020-12/vocabulary.json | 10 +- src/output/output_jsonld.cc | 132 +++++++++++++----- test/output/output_jsonld_test.cc | 80 +++++++++++ 4 files changed, 186 insertions(+), 46 deletions(-) diff --git a/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json index d9510d8af..1331dedca 100644 --- a/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json +++ b/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -97,8 +97,7 @@ "type": "null" }, { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } ] }, @@ -168,6 +167,10 @@ } }, "$defs": { + "language-tag": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + }, "constants-entry": { "anyOf": [ { @@ -224,8 +227,7 @@ "type": "string" }, "@language": { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } }, "additionalProperties": false diff --git a/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json index 65cebc488..27a0dad4b 100644 --- a/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json +++ b/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -97,8 +97,7 @@ "type": "null" }, { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } ] }, @@ -168,6 +167,10 @@ } }, "$defs": { + "language-tag": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + }, "constants-entry": { "anyOf": [ { @@ -224,8 +227,7 @@ "type": "string" }, "@language": { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } }, "additionalProperties": false diff --git a/src/output/output_jsonld.cc b/src/output/output_jsonld.cc index fae9125b6..67c8e0696 100644 --- a/src/output/output_jsonld.cc +++ b/src/output/output_jsonld.cc @@ -985,25 +985,80 @@ const auto HASH_VALUE{sourcemeta::core::JSON::Object::hash("x-jsonld-value"sv)}; const auto HASH_CONSTANTS{ sourcemeta::core::JSON::Object::hash("x-jsonld-constants"sv)}; -// Collect the consent paths of a promoted location from the collected -// annotations -auto collect_consent_paths( - const sourcemeta::blaze::SimpleOutput &output, - const sourcemeta::core::WeakPointer &location, - const sourcemeta::core::JSON::String &value_predicate) -> ConsentPaths { - ConsentPaths result; +// The retargeting-relevant annotation kinds of the consent index +enum class ConsentKind : std::uint8_t { + ValuePredicate, + OverrideMark, + Datatype, + Language, + Direction, + Self +}; + +// One retargeting-relevant annotation at a location +struct ConsentAnnotation { + ConsentKind kind; + const sourcemeta::core::WeakPointer *path; + const sourcemeta::core::JSON *value; +}; + +// The retargeting-relevant annotations of every location, built in one pass +// the first time any promoted location needs consent, so that consent stays +// linear in the collected annotations no matter how many locations promote +using ConsentIndex = std::unordered_map, + sourcemeta::core::WeakPointer::Hasher>; + +auto build_consent_index(const sourcemeta::blaze::SimpleOutput &output) + -> ConsentIndex { + ConsentIndex index; for (const auto &entry : output.annotations()) { - if (entry.evaluate_path.empty() || entry.instance_location != location) { + if (entry.evaluate_path.empty()) { continue; } const auto &keyword{entry.evaluate_path.back()}; - if (keyword.property_equals("x-jsonld-value", HASH_VALUE) && - entry.value.is_string() && entry.value.to_string() == value_predicate) { - result.values.push_back(&entry.evaluate_path); - } else if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE) && - entry.value.is_boolean() && entry.value.to_boolean()) { - result.marks.push_back(&entry.evaluate_path); + std::optional kind; + if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + if (entry.value.is_string()) { + kind = ConsentKind::ValuePredicate; + } + } else if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { + if (entry.value.is_boolean() && entry.value.to_boolean()) { + kind = ConsentKind::OverrideMark; + } + } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { + kind = ConsentKind::Datatype; + } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { + kind = ConsentKind::Language; + } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { + kind = ConsentKind::Direction; + } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { + kind = ConsentKind::Self; + } + + if (kind.has_value()) { + index[entry.instance_location].push_back({.kind = kind.value(), + .path = &entry.evaluate_path, + .value = &entry.value}); + } + } + + return index; +} + +// The consent paths of a promoted location: the declarations of its +// resolved value predicate and the override marks beside them +auto consent_paths_at(const std::vector &annotations, + const sourcemeta::core::JSON::String &value_predicate) + -> ConsentPaths { + ConsentPaths result; + for (const auto &annotation : annotations) { + if (annotation.kind == ConsentKind::ValuePredicate && + annotation.value->to_string() == value_predicate) { + result.values.push_back(annotation.path); + } else if (annotation.kind == ConsentKind::OverrideMark) { + result.marks.push_back(annotation.path); } } @@ -1013,20 +1068,12 @@ auto collect_consent_paths( // Whether any declaration of the given facet value at the location is // consented to follow the value predicate into the promoted node auto facet_retargeting_consented( - const sourcemeta::blaze::SimpleOutput &output, - const sourcemeta::core::WeakPointer &location, const ConsentPaths &consent, - const sourcemeta::core::JSON::StringView keyword_name, - const sourcemeta::core::JSON::Object::hash_type keyword_hash, + const std::vector &annotations, + const ConsentPaths &consent, const ConsentKind kind, const sourcemeta::core::JSON &expected) -> bool { - for (const auto &entry : output.annotations()) { - if (entry.evaluate_path.empty() || entry.instance_location != location) { - continue; - } - - const auto &keyword{entry.evaluate_path.back()}; - if (keyword.property_equals(keyword_name, keyword_hash) && - entry.value == expected && - declaration_consented(consent, entry.evaluate_path)) { + for (const auto &annotation : annotations) { + if (annotation.kind == kind && *annotation.value == expected && + declaration_consented(consent, *annotation.path)) { return true; } } @@ -1332,10 +1379,11 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &fragment{std::get(canonical)}; // Whether a null entry is a licensed tombstone depends on override - // marks that only the slow path can consult + // marks that only the slow path can consult, and an empty fragment + // asserts nothing, so it must not materialize the promotion facts if (fragment_has_null_entry(fragment)) { demote(accumulator, dirty, instance_location); - } else if (!dirty.contains(instance_location)) { + } else if (!fragment.empty() && !dirty.contains(instance_location)) { auto &promotion{promotion_facts(accumulator[instance_location])}; if (merge_constants_fragment(promotion.constants, fragment)) { promotion.constants_origin = origin; @@ -1524,6 +1572,7 @@ auto resolve(const sourcemeta::core::JSON &instance, } } + std::optional consent_index; sourcemeta::core::JSONLDWeakAnnotationList annotations; annotations.reserve(accumulator.size()); for (auto &[pointer, facts] : accumulator) { @@ -1550,7 +1599,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.container.has_value()) { if (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.json || facts.self.has_value() || facts.promotion != nullptr) { + facts.json || facts.self.has_value() || promoted(facts) || + with_constants(facts)) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD container can only be combined with " @@ -1576,7 +1626,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.json && (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.self.has_value() || facts.promotion != nullptr)) { + facts.self.has_value() || promoted(facts) || with_constants(facts))) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::JSON, "A JSON-LD JSON literal can only be combined with " @@ -1613,12 +1663,18 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.self.has_value() || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value()) { - const auto consent{ - collect_consent_paths(output, pointer, promotion.value.value())}; + if (!consent_index.has_value()) { + consent_index = build_consent_index(output); + } + + const auto location_annotations{consent_index->find(pointer)}; + assert(location_annotations != consent_index->cend()); + const auto &nearby{location_annotations->second}; + const auto consent{consent_paths_at(nearby, promotion.value.value())}; if (facts.self.has_value() && !facet_retargeting_consented( - output, pointer, consent, "x-jsonld-self", HASH_SELF, + nearby, consent, ConsentKind::Self, sourcemeta::core::JSON{facts.self.value()})) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::ValuePredicate, @@ -1629,7 +1685,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.datatype.has_value() && !facet_retargeting_consented( - output, pointer, consent, "x-jsonld-datatype", HASH_DATATYPE, + nearby, consent, ConsentKind::Datatype, sourcemeta::core::JSON{facts.datatype.value()})) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::ValuePredicate, @@ -1640,7 +1696,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.language.has_value() && !facet_retargeting_consented( - output, pointer, consent, "x-jsonld-language", HASH_LANGUAGE, + nearby, consent, ConsentKind::Language, sourcemeta::core::JSON{facts.language.value()})) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::ValuePredicate, @@ -1650,8 +1706,8 @@ auto resolve(const sourcemeta::core::JSON &instance, } if (facts.direction.has_value() && - !facet_retargeting_consented(output, pointer, consent, - "x-jsonld-direction", HASH_DIRECTION, + !facet_retargeting_consented(nearby, consent, + ConsentKind::Direction, sourcemeta::core::JSON{direction_text( facts.direction.value())})) { return facet_error(pointer, diff --git a/test/output/output_jsonld_test.cc b/test/output/output_jsonld_test.cc index 2aad9f67e..0de712749 100644 --- a/test/output/output_jsonld_test.cc +++ b/test/output/output_jsonld_test.cc @@ -15483,3 +15483,83 @@ TEST(JSONLD_value_retargeting_consent_survives_demotion) { EXPECT_JSON_LD_VALUE(schema, instance, expected); } + +TEST(JSONLD_constants_empty_map_with_json_literal_is_a_noop) { + 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://schema.org/x", + "x-jsonld-json": true, + "x-jsonld-constants": {} + } + } + })JSON")}; + + const auto instance{sourcemeta::core::parse_json(R"JSON({ "x": 1 })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ + { "@value": 1, "@type": "@json" } + ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_empty_map_with_container_is_a_noop) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "array", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-container": "@set", + "x-jsonld-constants": {} + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ { "@value": 1 } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +} + +TEST(JSONLD_constants_empty_array_entry_with_container_is_a_noop) { + const auto schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "x": { + "type": "array", + "x-jsonld-id": "https://schema.org/x", + "x-jsonld-container": "@set", + "x-jsonld-constants": { + "https://example.com/seeAlso": [] + } + } + } + })JSON")}; + + const auto instance{ + sourcemeta::core::parse_json(R"JSON({ "x": [ 1 ] })JSON")}; + + const auto expected{sourcemeta::core::parse_json(R"JSON([ + { + "https://schema.org/x": [ { "@value": 1 } ] + } + ])JSON")}; + + EXPECT_JSON_LD_VALUE(schema, instance, expected); +}