Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions src/oold/validation/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
114 changes: 114 additions & 0 deletions tests/test_validation/test_jsonld.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>"
nq = "\n".join([
f"<https://example.org/jane> {rdf_type} <http://schema.org/Person> .",
"<https://example.org/jane> <http://schema.org/worksFor> <https://example.org/acme> .",
f"<https://example.org/joe> {rdf_type} <http://schema.org/Person> .",
"<https://example.org/joe> <http://schema.org/worksFor> <https://example.org/acme> .",
f"<https://example.org/acme> {rdf_type} <http://schema.org/Organization> .",
'<https://example.org/acme> <http://schema.org/name> "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


Expand Down
Loading