diff --git a/semantica/export/json_exporter.py b/semantica/export/json_exporter.py index c1906250..0c3b492c 100644 --- a/semantica/export/json_exporter.py +++ b/semantica/export/json_exporter.py @@ -416,7 +416,13 @@ class JSONExporter: # Convert data based on type if isinstance(data, dict): - if _is_jsonld_document(data): + # A knowledge graph is converted even when it carries a context of + # its own: the specialized conversion is what mints entity ids and + # relationship endpoints, and skipping it leaves them raw keys. + if "entities" in data or "relationships" in data: + # Knowledge graph structure - use specialized conversion + jsonld.update(self._convert_kg_to_jsonld(data, **options)) + elif _is_jsonld_document(data): # Already JSON-LD: merge it rather than nesting it. Wrapping a # converted document in @graph re-typed the payload as a named # graph and doubled the @context, which is what happened when @@ -424,10 +430,15 @@ class JSONExporter: context = data.get("@context") if isinstance(context, dict): jsonld["@context"].update(context) + elif context is not None: + # A context may also be a URL or an array of them, which + # cannot be merged key by key. Keeping both as an array + # preserves the caller's term expansion, which wins over + # ours, while still defining the semantica prefix. An + # explicit null is left alone: in an array it would reset + # the active context and take our own terms with it. + jsonld["@context"] = [jsonld["@context"], context] jsonld.update({k: v for k, v in data.items() if k != "@context"}) - elif "entities" in data or "relationships" in data: - # Knowledge graph structure - use specialized conversion - jsonld.update(self._convert_kg_to_jsonld(data, **options)) else: # Generic dictionary - wrap in @graph jsonld["@graph"] = [data] @@ -463,20 +474,31 @@ class JSONExporter: jsonld: Document being built, modified in place include_provenance: Whether to record how and when it was exported """ + # A caller may hand us a document that is deliberately a named graph. + # That name is theirs to keep, but our own statements must not end up + # inside it, where a default-graph reader would never see them. + payload_is_named_graph = "@id" in jsonld and "@graph" in jsonld + document: Dict[str, Any] = {} # Do not overwrite an identifier the payload already carries: the # knowledge-graph conversion names its own document node. - if "@id" not in jsonld: + if "@id" not in jsonld or payload_is_named_graph: document["@id"] = f"https://semantica.dev/data/{utc_now_iso()}" if include_provenance: document["semantica:exportedAt"] = utc_now_iso() document["semantica:format"] = "json-ld" - if "@graph" in jsonld: - document.setdefault( - "@id", f"https://semantica.dev/data/{utc_now_iso()}" - ) - jsonld["@graph"] = list(jsonld["@graph"]) + [document] + if payload_is_named_graph: + named = {key: value for key, value in jsonld.items() if key != "@context"} + for key in [key for key in jsonld if key != "@context"]: + del jsonld[key] + jsonld["@graph"] = [named, document] + elif "@graph" in jsonld: + # @graph may be a single node object as well as an array. list() on + # a dictionary yields its keys, which would discard the node. + members = jsonld["@graph"] + members = list(members) if isinstance(members, list) else [members] + jsonld["@graph"] = members + [document] else: jsonld.update(document) diff --git a/tests/export/test_jsonld_default_graph.py b/tests/export/test_jsonld_default_graph.py index 8d3f08a6..c66b9a1a 100644 --- a/tests/export/test_jsonld_default_graph.py +++ b/tests/export/test_jsonld_default_graph.py @@ -141,3 +141,78 @@ def test_document_provenance_still_reaches_the_default_graph(tmp_path): str(s) for s in graph.subjects() } assert {"A", "B"} <= {str(o) for o in graph.objects()} + + +# The document a caller hands to export() need not be one Semantica built, and +# the branch that recognises an already-converted document has to survive every +# shape JSON-LD allows. Each of the four cases below regressed when that branch +# was first written. + + +def test_a_url_valued_context_is_not_thrown_away(tmp_path): + """@context may be a URL or an array, not only an object.""" + payload = { + "@context": "https://schema.org/", + "@id": "https://example.org/thing", + "name": "Acme Corp", + } + path = _write(payload, tmp_path) + context = json.loads(path.read_text())["@context"] + + flattened = context if isinstance(context, list) else [context] + assert "https://schema.org/" in flattened, ( + "the caller's context was replaced by Semantica's defaults, " + "which silently changes how every term expands" + ) + + +def test_a_graph_given_as_one_node_object_survives(tmp_path): + """@graph may be a single node object; list() on it yields its keys.""" + payload = { + "@context": {"rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@graph": {"@id": "https://example.org/only", "rdfs:label": "Only"}, + } + path = _write(payload, tmp_path) + + graph = Graph() + graph.parse(str(path), format="json-ld") + assert "Only" in {str(o) for o in graph.objects()} + + +def test_a_caller_supplied_named_graph_keeps_our_provenance_readable(tmp_path): + """A deliberate named graph stays named, but must not swallow the export's own metadata.""" + payload = { + "@context": {"rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@id": "https://example.org/named", + "@graph": [{"@id": "https://example.org/n1", "rdfs:label": "N1"}], + } + path = _write(payload, tmp_path) + document = json.loads(path.read_text()) + + assert "@id" not in document or "@graph" not in document + + graph = Graph() + graph.parse(str(path), format="json-ld") + predicates = {str(p) for p in graph.predicates()} + assert "https://semantica.dev/ns#exportedAt" in predicates, ( + "the export's provenance was written inside the caller's named graph, " + "where a default-graph reader cannot see it" + ) + + dataset = Dataset() + dataset.parse(str(path), format="json-ld") + names = {str(c.identifier) for c in dataset.graphs()} + assert "https://example.org/named" in names, "the caller's graph lost its name" + + +def test_a_knowledge_graph_carrying_a_context_is_still_converted(tmp_path): + """entities/relationships must win over the already-JSON-LD branch.""" + payload = dict(KG, **{"@context": {"ex": "https://example.org/ns#"}}) + path = _write(payload, tmp_path) + document = json.loads(path.read_text()) + + assert "semantica:entities" in document, ( + "the knowledge graph skipped its own conversion, so entity ids, " + "endpoints, types and confidences were left as raw keys" + ) + assert "entities" not in document