From 0e04a1a87b3e3db077a3f76d7252e56ee6cbf621 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Sat, 19 Sep 2026 15:41:50 +0200 Subject: [PATCH] fix(frame): keep reference-valued properties as IRIs schema_to_frame emitted no subframe for reference-valued properties, on the assumption that a referenced IRI carries no local triples. Where the target does carry triples in the same graph, framing embedded it as an object and the framed document stopped validating against the schema the frame came from. Reference signals per OOLD-EXT-68fa: x-oold-range, an IRI-family format, or a term mapped "@type": "@id". Embedding wins where both appear. keyword_alias_keys excludes keys aliasing a JSON-LD keyword: id carries an IRI format and so matches, but a subframe there writes {"@id": {...}}, which pyld rejects. Found through allOf, since the base schema declares the convention. Refs OO-LD/oold-schema#160 --- src/oold/validation/frame.py | 88 ++++++++++++++++++++- tests/test_validation/test_jsonld.py | 114 +++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/src/oold/validation/frame.py b/src/oold/validation/frame.py index 2e24092..d87c7b0 100644 --- a/src/oold/validation/frame.py +++ b/src/oold/validation/frame.py @@ -12,10 +12,13 @@ as sibling graph nodes; * ``@context`` is the schema's own context, or a reference to it, so terms compact back to their property names; -* an empty subframe ``{}`` is added per property that embeds an object. +* an empty subframe ``{}`` is added per property that embeds an object; +* ``{"@embed": "@never"}`` is added per reference-valued property, so its targets stay IRIs. -Reference-valued and literal properties need no subframe: a referenced IRI with no local triples -stays ``{"id": ...}`` and literals compact directly. +Literal properties need no subframe, since literals compact directly. Reference-valued ones do: +where the referenced node carries triples in the same graph, framing would otherwise pull them +in as an object, and the framed document would stop validating against the schema the frame was +derived from, which declares a string there. Use with ``jsonld.frame(rdf, frame, {"omitDefault": True})`` so a property absent from a given instance is omitted rather than emitted as null. @@ -31,6 +34,9 @@ #: JSON-LD context value. _UNSET = object() +#: The IRI/URI-family formats ``OOLD-EXT-6ea3`` recommends for an IRI-valued property. +IRI_FORMATS = frozenset({"iri", "iri-reference", "uri", "uri-reference"}) + def is_embed(node: Any) -> bool: """True when a property's schema describes an embedded *object* value. @@ -113,6 +119,80 @@ def instance_rdf_types(schema: Any) -> list[str] | None: return None +def keyword_alias_keys(schema: dict[str, Any]) -> set[str]: + """Property names that alias a JSON-LD keyword, such as ``id`` for ``@id``. + + These are not predicates: ``id`` names the node, it does not point at another one. Putting a + subframe under such a key writes ``{"@id": {...}}`` into the frame, which a processor rejects + outright ("@id value must be a string"). + + The alias is searched across the composed schema because a dereferenced subclass chain keeps + each superclass's own ``@context`` on its ``allOf`` member, and the convention is usually + declared by the base schema rather than repeated by every subclass. + """ + found: set[str] = set() + + def scan_context(context: Any) -> None: + if isinstance(context, list): + for entry in context: + scan_context(entry) + elif isinstance(context, dict): + for term, definition in context.items(): + if term.startswith("@"): + continue + target = definition.get("@id") if isinstance(definition, dict) else definition + if isinstance(target, str) and target.startswith("@"): + found.add(term) + + def walk(node: Any) -> None: + if not isinstance(node, dict): + return + scan_context(node.get("@context")) + for sub in node.get("allOf") or []: + walk(sub) + + walk(schema) + return found + + +def reference_properties(schema: dict[str, Any]) -> list[str]: + """Properties whose value is a reference, so framing must leave it an IRI. + + Three signals, per ``OOLD-EXT-68fa``: an ``x-oold-range`` on a string-typed value, an + IRI-family ``format`` (the family ``OOLD-EXT-6ea3`` recommends), or a context term mapped + ``"@type": "@id"``. + + Embedding takes precedence where a property carries both: a property shaped like an object + is an embed whatever its term says. + """ + + def is_reference(node: Any) -> bool: + if not isinstance(node, dict): + return False + if node.get("items") is not None: + return is_reference(node["items"]) + if "x-oold-range" in node: + return True + if node.get("format") in IRI_FORMATS: + return True + for keyword in ("anyOf", "oneOf", "allOf"): + branches = node.get(keyword) + if isinstance(branches, list) and any(is_reference(branch) for branch in branches): + return True + return False + + properties = collect_composed_properties(schema) + terms = context_terms(schema.get("@context")) + aliases = keyword_alias_keys(schema) + return [ + name + for name, prop in properties.items() + if name not in aliases + and not is_embed(prop) + and (is_reference(prop) or terms.get(name, {}).get("@type") == "@id") + ] + + def schema_to_frame(schema: dict[str, Any], context_ref: Any = _UNSET) -> dict[str, Any]: """Derive the minimal frame for reconstructing this schema's instances. @@ -127,4 +207,6 @@ def schema_to_frame(schema: dict[str, Any], context_ref: Any = _UNSET) -> dict[s frame["@type"] = types[0] if len(types) == 1 else types for name in embedded_properties(schema): frame[name] = {} + for name in reference_properties(schema): + frame[name] = {"@embed": "@never"} return frame diff --git a/tests/test_validation/test_jsonld.py b/tests/test_validation/test_jsonld.py index 44c7374..6842ec5 100644 --- a/tests/test_validation/test_jsonld.py +++ b/tests/test_validation/test_jsonld.py @@ -12,6 +12,8 @@ embedded_properties, instance_rdf_types, is_embed, + keyword_alias_keys, + reference_properties, schema_to_frame, ) from oold.validation.loader import DocumentLoader, describe_jsonld_error @@ -144,6 +146,118 @@ def test_embedded_properties_ignores_a_scoped_term_that_is_not_a_property_here() assert "amount" not in schema_to_frame(schema, "https://oo-ld.test/x/C.schema.json") +def test_reference_properties_detects_each_signal(): + schema = { + "@context": {"works_for": {"@id": "schema:worksFor", "@type": "@id"}}, + "properties": { + "ranged": {"type": "string", "x-oold-range": "Person.schema.json"}, + "formatted": {"type": "string", "format": "iri-reference"}, + "works_for": {"type": "string"}, + "listed": {"type": "array", "items": {"type": "string", "format": "iri"}}, + "literal": {"type": "string"}, + }, + } + assert sorted(reference_properties(schema)) == [ + "formatted", + "listed", + "ranged", + "works_for", + ] + + +def test_embedding_wins_where_a_property_carries_both_signals(): + """A property shaped like an object is an embed whatever its term says.""" + schema = { + "@context": {"address": {"@id": "schema:address", "@type": "@id"}}, + "properties": {"address": {"type": "object", "properties": {"zip": {}}}}, + } + assert embedded_properties(schema) == ["address"] + assert reference_properties(schema) == [] + + +def test_a_keyword_alias_never_gets_a_subframe(): + """``id`` is the node's name, not a predicate. + + Thing.schema.json declares ``id`` with an IRI format while its context aliases it to ``@id``, + so the reference signals match. A subframe there writes ``{"@id": {...}}``, which a processor + rejects. The alias is declared by the base schema, so it is found through ``allOf``. + """ + schema = { + "@context": {"schema": "http://schema.org/"}, + "allOf": [ + { + "@context": {"id": "@id", "type": "@type"}, + "properties": {"id": {"type": "string", "format": "iri"}}, + } + ], + "properties": {"ref": {"type": "string", "format": "iri-reference"}}, + } + assert keyword_alias_keys(schema) == {"id", "type"} + assert reference_properties(schema) == ["ref"] + assert "id" not in schema_to_frame(schema, "X.schema.json") + + +def test_schema_to_frame_keeps_reference_valued_properties_as_iris(): + """The worked example in the specification's #framing section. + + Without the @never subframe a referenced node that carries triples in the same graph is + embedded, and the framed document stops validating against the schema it came from. + """ + schema = { + "@context": { + "address": {"@id": "schema:address", "@context": "Address.schema.json"}, + "employees": {"@reverse": "schema:worksFor", "@type": "@id"}, + }, + "x-oold-instance-rdf-type": ["schema:Organization"], + "properties": { + "address": {"type": "object", "properties": {"postalCode": {}}}, + "employees": { + "type": "array", + "items": {"type": "string", "x-oold-range": "Person.schema.json"}, + }, + }, + } + frame = schema_to_frame(schema, "Organization.schema.json") + assert frame["address"] == {} + assert frame["employees"] == {"@embed": "@never"} + + +def test_framing_leaves_a_reference_whose_target_has_triples_as_an_iri(): + """OO-LD/oold-schema#160.""" + schema = { + "@context": { + "schema": "http://schema.org/", + "type": "@type", + "id": "@id", + "works_for": {"@id": "schema:worksFor", "@type": "@id"}, + "name": "schema:name", + }, + "x-oold-instance-rdf-type": ["schema:Person"], + "type": "object", + "properties": { + "name": {"type": "string"}, + "works_for": {"type": "string", "format": "iri-reference"}, + }, + } + rdf_type = "" + nq = "\n".join([ + f" {rdf_type} .", + " .", + f" {rdf_type} .", + " .", + f" {rdf_type} .", + ' "ACME" .', + "", + ]) + graph = jsonld.from_rdf(nq, {"format": "application/n-quads"}) + framed = jsonld.frame(graph, schema_to_frame(schema), {"omitDefault": True}) + people = framed.get("@graph", [framed]) + + assert people, "framing returned nothing" + for person in people: + assert isinstance(person["works_for"], str), f"{person['id']}: a reference must not absorb the target's triples" + + # ------------------------------------------------------------------ loader