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