diff --git a/CLAUDE.md b/CLAUDE.md index a339559..8025bd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,27 @@ When adding new operations: 3. Place in `src/web_algebra/operations/` directory for auto-discovery 4. Use `@op` and `args` structure in JSON examples +### RDF type convention + +The system works in the rdflib type system (`Node`, `Result`, `Graph`); JSON is +only the transport. Conversion happens at fixed boundaries — operations do not +pass raw JSON around: + +- **`execute()` is pure rdflib** — it takes and returns `Node` / `Result` / + `Graph` only. Never accept a `dict` here. +- **`execute_json()` is the JSON boundary** — it resolves its arguments with + `Operation.process_json()` (which evaluates nested `@op`/variable references) + and then converts any JSON-LD body to a `Graph` with **`Operation.to_graph()`** + before calling `execute()`. `to_graph` is the *single* place JSON-LD is parsed + and the only place an operation applies its base IRI + (`to_graph(data, base=)`), which is needed to resolve relative + IRIs. An input that is already a `Graph` (e.g. from an upstream `CONSTRUCT`) + passes through unchanged. +- **`process_json()` never parses JSON-LD to a `Graph`** — it resolves + `@op`/variables in place and returns plain data (`dict`/`list`/term). It can't + know an op's base IRI, and eager parsing would freeze unresolved `@op` holes + (an op-valued `@id` would silently become a blank node). + ### JSON DSL Format Operations must follow this structure: diff --git a/src/web_algebra/operation.py b/src/web_algebra/operation.py index 70902c7..fd26ea9 100644 --- a/src/web_algebra/operation.py +++ b/src/web_algebra/operation.py @@ -10,10 +10,10 @@ from rdflib.query import Result -# JSON-LD keyword set used to recognise a top-level dict as RDF data rather -# than generic JSON to recurse into. Presence of any of these at the root is -# a strong, unambiguous signal — they are JSON-LD reserved terms with no -# legitimate meaning in non-RDF JSON. +# JSON-LD keyword set used to recognise a dict as RDF data (a JSON-LD +# document/fragment) rather than generic JSON to recurse into. Presence of any +# of these is a strong, unambiguous signal — they are JSON-LD reserved terms +# with no legitimate meaning in non-RDF JSON. _JSONLD_KEYS = ("@context", "@graph", "@id", "@type") @@ -100,18 +100,23 @@ def process_json( return result # JSON-LD shape recognition — a dict carrying any JSON-LD reserved - # key is RDF data, not generic JSON to recurse into. Parse it - # once at the runtime layer so every consuming op (POST, PUT, - # Merge, ldh-Create*/Add*) receives an `rdflib.Graph` directly - # instead of re-parsing identically inside its own - # `execute_json`. Symmetric with the bare-value auto-wrap - # below: that branch turns a JSON scalar into the matching - # RDFLib term; this branch turns a JSON-LD object into the - # matching RDFLib graph. + # key is RDF data (a JSON-LD document or fragment), not generic + # JSON to recurse into. It may still embed `@op` operations or + # variable references in its values (e.g. an `@id` computed from a + # runtime binding), so resolve those in place but keep the dict + # intact and let the consuming op parse it. + # + # We deliberately do NOT parse to an `rdflib.Graph` here. Every op + # that consumes a JSON-LD body (POST, PUT, Merge, ldh Create*/Add*) + # already parses a dict itself — and does so with the right base + # IRI (`publicID=`), which a central parse here cannot + # know. Parsing centrally would also freeze any unresolved `@op` + # holes: `@id` expects a string IRI, so an op-valued `@id` would + # silently become a blank node and the intended URI would be lost. if any(k in json_data for k in _JSONLD_KEYS): - graph = Graph() - graph.parse(data=json.dumps(json_data), format="json-ld") - return graph + return cls._resolve_jsonld( + settings, json_data, context, variable_stack + ) # 🔁 Recurse into each value — allows nested @op inside generic # JSON structures (e.g. SPARQL binding objects), without @@ -135,6 +140,40 @@ def process_json( # Convert plain values to RDFLib terms return cls.json_to_rdflib(json_data) + @classmethod + def _resolve_jsonld( + cls, + settings: BaseSettings, + json_data: Any, + context: dict = {}, + variable_stack: list = [], + ) -> Any: + """Resolve embedded `@op` nodes inside a JSON-LD document in place. + + Walks a JSON-LD-shaped structure evaluating only `@op` nodes (to their + RDFLib terms — `str` subclasses for URIs/literals, so they serialise + cleanly) while leaving every other scalar as plain JSON for the + consuming op's JSON-LD parser to interpret. Nested dicts/lists keep + their structure so the whole document serialises as one unit; in + particular, fragments that merely look like JSON-LD (e.g. a + `{"@id": "..."}` object reference) stay dicts rather than being parsed + into standalone Graphs. + """ + if isinstance(json_data, dict): + if "@op" in json_data: + return cls.process_json(settings, json_data, context, variable_stack) + return { + k: cls._resolve_jsonld(settings, v, context, variable_stack) + for k, v in json_data.items() + } + elif isinstance(json_data, list): + return [ + cls._resolve_jsonld(settings, item, context, variable_stack) + for item in json_data + ] + else: + return json_data + @staticmethod def _serialize_for_json_context(obj) -> Any: """Convert RDFLib objects to appropriate format for JSON consumption""" @@ -172,6 +211,31 @@ def get_variable(self, name: str, variable_stack: list) -> Any: raise ValueError(f"Variable '{name}' not found") # Conversion helpers between different formats + @staticmethod + def to_graph(data: Any, *, base: Optional[str] = None) -> Graph: + """Convert a JSON-LD document into an `rdflib.Graph`. + + The single conversion point from JSON-LD transport into the rdflib + type system. Operations resolve their arguments to plain data (via + `process_json`) and then call this to hand `execute` a `Graph` — so + `execute` always works in rdflib terms, never raw JSON. Input that is + already a `Graph` (e.g. the output of an upstream op such as CONSTRUCT) + passes through unchanged. + + `base` is the document's base IRI — typically the target URL of the + enclosing op — used to resolve any relative IRIs in the JSON-LD. + """ + if isinstance(data, Graph): + return data + if isinstance(data, dict): + graph = Graph() + graph.parse(data=json.dumps(data), format="json-ld", publicID=base) + return graph + raise TypeError( + f"Cannot convert {type(data).__name__} to Graph; " + "expected a JSON-LD dict or an rdflib.Graph" + ) + @staticmethod def json_to_rdflib(data) -> Node: """Convert JSON/binding objects to RDFLib terms""" diff --git a/src/web_algebra/operations/linked_data/post.py b/src/web_algebra/operations/linked_data/post.py index aaa953c..40242c5 100644 --- a/src/web_algebra/operations/linked_data/post.py +++ b/src/web_algebra/operations/linked_data/post.py @@ -1,5 +1,4 @@ from typing import Any -import json import logging from rdflib import URIRef, Graph, Literal from rdflib.namespace import XSD @@ -96,22 +95,8 @@ def execute_json(self, arguments: dict, variable_stack: list = []) -> Result: self.settings, arguments["data"], self.context, variable_stack ) - # Convert processed data to Graph object - if isinstance(data_result, Graph): - # Already a Graph (from some operation result) - graph_data = data_result - elif isinstance(data_result, dict): - # Processed JSON-LD - convert to Graph - import json - - json_str = json.dumps(data_result) - graph = Graph() - graph.parse(data=json_str, format="json-ld", publicID=str(url_data)) - graph_data = graph - else: - raise TypeError( - f"POST operation expects data to be Graph or dict, got {type(data_result)}" - ) + # Convert resolved JSON-LD into a Graph at the target's base IRI + graph_data = self.to_graph(data_result, base=str(url_data)) return self.execute(url_data, graph_data) @@ -119,11 +104,8 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" url = URIRef(arguments["url"]) - # Convert JSON-LD data to Graph - data_dict = arguments["data"] - json_str = json.dumps(data_dict) - graph = Graph() - graph.parse(data=json_str, format="json-ld", publicID=str(url)) + # Convert JSON-LD data to Graph at the target's base IRI + graph = self.to_graph(arguments["data"], base=str(url)) result = self.execute(url, graph) diff --git a/src/web_algebra/operations/linked_data/put.py b/src/web_algebra/operations/linked_data/put.py index 1300673..16a5760 100644 --- a/src/web_algebra/operations/linked_data/put.py +++ b/src/web_algebra/operations/linked_data/put.py @@ -1,5 +1,4 @@ from typing import Any -import json import logging from rdflib import URIRef, Graph, Literal from rdflib.namespace import XSD @@ -96,22 +95,8 @@ def execute_json(self, arguments: dict, variable_stack: list = []) -> Result: self.settings, arguments["data"], self.context, variable_stack ) - # Convert processed data to Graph object - if isinstance(data_result, Graph): - # Already a Graph (from some operation result) - graph_data = data_result - elif isinstance(data_result, dict): - # Processed JSON-LD - convert to Graph - import json - - json_str = json.dumps(data_result) - graph = Graph() - graph.parse(data=json_str, format="json-ld", publicID=str(url_data)) - graph_data = graph - else: - raise TypeError( - f"PUT operation expects data to be Graph or dict, got {type(data_result)}" - ) + # Convert resolved JSON-LD into a Graph at the target's base IRI + graph_data = self.to_graph(data_result, base=str(url_data)) return self.execute(url_data, graph_data) @@ -119,11 +104,8 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" url = URIRef(arguments["url"]) - # Convert JSON-LD data to Graph - data_dict = arguments["data"] - json_str = json.dumps(data_dict) - graph = Graph() - graph.parse(data=json_str, format="json-ld", publicID=str(url)) + # Convert JSON-LD data to Graph at the target's base IRI + graph = self.to_graph(arguments["data"], base=str(url)) result = self.execute(url, graph) diff --git a/src/web_algebra/operations/linkeddatahub/add_generic_service.py b/src/web_algebra/operations/linkeddatahub/add_generic_service.py index 291ab7c..403943d 100644 --- a/src/web_algebra/operations/linkeddatahub/add_generic_service.py +++ b/src/web_algebra/operations/linkeddatahub/add_generic_service.py @@ -246,13 +246,8 @@ def execute( logging.info(f"Posting service description with JSON-LD data: {data}") - # POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - import json - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py index e3c952e..eac7a3a 100644 --- a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py +++ b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py @@ -252,13 +252,8 @@ def execute( logging.info(f"Posting ResultSetChart with JSON-LD data: {data}") - # POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - import json - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/add_select.py b/src/web_algebra/operations/linkeddatahub/add_select.py index ba69980..d1bf1c4 100644 --- a/src/web_algebra/operations/linkeddatahub/add_select.py +++ b/src/web_algebra/operations/linkeddatahub/add_select.py @@ -195,13 +195,8 @@ def execute( logging.info(f"Posting SELECT query with JSON-LD data: {data}") - # POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - import json - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/add_view.py b/src/web_algebra/operations/linkeddatahub/add_view.py index 615b098..44eab5a 100644 --- a/src/web_algebra/operations/linkeddatahub/add_view.py +++ b/src/web_algebra/operations/linkeddatahub/add_view.py @@ -217,13 +217,8 @@ def execute( logging.info(f"Posting View with JSON-LD data: {data}") - # POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - import json - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py index 13148b0..e281efd 100644 --- a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py +++ b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py @@ -271,12 +271,8 @@ def execute( logging.info(f"Posting object block with JSON-LD data: {data}") - # Step 6: POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Step 6: Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/content/add_xhtml_block.py b/src/web_algebra/operations/linkeddatahub/content/add_xhtml_block.py index 90ed4ee..9f96992 100644 --- a/src/web_algebra/operations/linkeddatahub/content/add_xhtml_block.py +++ b/src/web_algebra/operations/linkeddatahub/content/add_xhtml_block.py @@ -241,12 +241,8 @@ def execute( logging.info(f"Posting XHTML block with JSON-LD data: {data}") - # Step 6: POST the JSON-LD content to the target URI - # Convert JSON-LD dict to Graph - from rdflib import Graph - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str) + # Step 6: Convert the JSON-LD content to a Graph and POST to the target URI + graph = self.to_graph(data, base=url_str) return super().execute(url, graph) def mcp_run(self, arguments: dict, context: Any = None) -> Any: diff --git a/src/web_algebra/operations/linkeddatahub/content/generate_class_containers.py b/src/web_algebra/operations/linkeddatahub/content/generate_class_containers.py index 89cfc59..0051aa0 100644 --- a/src/web_algebra/operations/linkeddatahub/content/generate_class_containers.py +++ b/src/web_algebra/operations/linkeddatahub/content/generate_class_containers.py @@ -218,15 +218,13 @@ def _build_view_graph(self, view_uri: URIRef, class_local: str, query_uri: URIRe def execute_json(self, arguments: dict, variable_stack: list = []) -> Result: """JSON execution: process arguments with type checking""" - # Process ontology graph - ontology_data = Operation.process_json( - self.settings, arguments["ontology"], self.context, variable_stack - ) - - if not isinstance(ontology_data, Graph): - raise TypeError( - f"GenerateClassContainers operation expects 'ontology' to be Graph, got {type(ontology_data)}" + # Process ontology graph — accept a Graph (e.g. from CONSTRUCT) or a + # JSON-LD document, converting the latter to a Graph + ontology_data = self.to_graph( + Operation.process_json( + self.settings, arguments["ontology"], self.context, variable_stack ) + ) # Process parent_container parent_container_data = Operation.process_json( diff --git a/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py b/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py index 0cf4224..f202c16 100644 --- a/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py +++ b/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py @@ -166,15 +166,13 @@ def _generate_sparql_query(self, property_uri: URIRef) -> str: def execute_json(self, arguments: dict, variable_stack: list = []) -> Graph: """JSON execution: process arguments with type checking""" - # Process ontology graph - ontology_data = Operation.process_json( - self.settings, arguments["ontology"], self.context, variable_stack - ) - - if not isinstance(ontology_data, Graph): - raise TypeError( - f"GenerateOntologyViews operation expects 'ontology' to be Graph, got {type(ontology_data)}" + # Process ontology graph — accept a Graph (e.g. from CONSTRUCT) or a + # JSON-LD document, converting the latter to a Graph + ontology_data = self.to_graph( + Operation.process_json( + self.settings, arguments["ontology"], self.context, variable_stack ) + ) # Process base_uri base_uri_data = Operation.process_json( diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index 0221b3e..59ff34b 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -1,7 +1,7 @@ from typing import Any, Optional import logging import urllib.parse -from rdflib import URIRef, Literal, Graph +from rdflib import URIRef, Literal from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation @@ -107,11 +107,8 @@ def execute( if description: data["dct:description"] = str(description) - # Create graph and call parent PUT operation - import json - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url) + # Convert the document to a Graph and call parent PUT operation + graph = self.to_graph(data, base=url) # Call parent PUT execute method return super().execute(URIRef(url), graph) diff --git a/src/web_algebra/operations/linkeddatahub/create_item.py b/src/web_algebra/operations/linkeddatahub/create_item.py index b2bda5d..1065e9f 100644 --- a/src/web_algebra/operations/linkeddatahub/create_item.py +++ b/src/web_algebra/operations/linkeddatahub/create_item.py @@ -1,7 +1,7 @@ from typing import Any, Optional import logging import urllib.parse -from rdflib import URIRef, Literal, Graph +from rdflib import URIRef, Literal from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation @@ -90,11 +90,8 @@ def execute( "dct:title": str(title), } - # Create graph and call parent PUT operation - import json - - graph = Graph() - graph.parse(data=json.dumps(data), format="json-ld", publicID=url) + # Convert the document to a Graph and call parent PUT operation + graph = self.to_graph(data, base=url) # Call parent PUT execute method return super().execute(URIRef(url), graph) diff --git a/src/web_algebra/operations/merge.py b/src/web_algebra/operations/merge.py index 2485ea1..278cfe8 100644 --- a/src/web_algebra/operations/merge.py +++ b/src/web_algebra/operations/merge.py @@ -1,4 +1,3 @@ -import json import logging from typing import Any, List from rdflib import Graph @@ -67,24 +66,8 @@ def execute_json(self, arguments: dict, variable_stack: list = []) -> Graph: f"Merge operation expects 'graphs' to be list, got {type(graphs_data)}" ) - # Convert processed data to Graph objects - graph_objects = [] - for item in graphs_data: - if isinstance(item, Graph): - # Already a Graph (from some operation result) - graph_objects.append(item) - elif isinstance(item, dict): - # Processed JSON-LD - convert to Graph - import json - - json_str = json.dumps(item) - graph = Graph() - graph.parse(data=json_str, format="json-ld") - graph_objects.append(graph) - else: - raise TypeError( - f"Merge operation expects graph items to be Graph or dict, got {type(item)}" - ) + # Convert each resolved JSON-LD item into a Graph + graph_objects = [self.to_graph(item) for item in graphs_data] # Delegate to execute() with Graph objects return self.execute(graph_objects) @@ -94,12 +77,7 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: graphs_data = arguments["graphs"] # Convert JSON-LD objects to RDF Graphs - graphs = [] - for data in graphs_data: - json_str = json.dumps(data) - graph = Graph() - graph.parse(data=json_str, format="json-ld") - graphs.append(graph) + graphs = [self.to_graph(data) for data in graphs_data] result_graph = self.execute(graphs) diff --git a/src/web_algebra/operations/sparql/construct.py b/src/web_algebra/operations/sparql/construct.py index f67b76b..a54dbab 100644 --- a/src/web_algebra/operations/sparql/construct.py +++ b/src/web_algebra/operations/sparql/construct.py @@ -1,6 +1,5 @@ import logging from typing import Any -import json from rdflib import URIRef, Literal, Graph from rdflib.namespace import XSD from mcp import types @@ -57,12 +56,8 @@ def execute(self, endpoint: URIRef, query: Literal) -> Graph: # Execute using the SPARQL client and get JSON-LD response json_ld_response = self.client.query(endpoint_url, query_str) - # Convert JSON-LD to RDF Graph - graph = Graph() - json_str = json.dumps(json_ld_response) - graph.parse(data=json_str, format="json-ld") - - return graph + # Convert JSON-LD response to RDF Graph + return self.to_graph(json_ld_response) def execute_json(self, arguments: dict, variable_stack: list = []) -> Graph: """JSON execution: process arguments and return Graph (same as execute)""" diff --git a/src/web_algebra/operations/sparql/describe.py b/src/web_algebra/operations/sparql/describe.py index 000adb2..be82c9a 100644 --- a/src/web_algebra/operations/sparql/describe.py +++ b/src/web_algebra/operations/sparql/describe.py @@ -1,6 +1,5 @@ import logging from typing import Any -import json from rdflib import URIRef, Literal, Graph from rdflib.namespace import XSD from mcp import types @@ -57,12 +56,8 @@ def execute(self, endpoint: URIRef, query: Literal) -> Graph: # Execute using the SPARQL client and get JSON-LD response json_ld_response = self.client.query(endpoint_url, query_str) - # Convert JSON-LD to RDF Graph - graph = Graph() - json_str = json.dumps(json_ld_response) - graph.parse(data=json_str, format="json-ld") - - return graph + # Convert JSON-LD response to RDF Graph + return self.to_graph(json_ld_response) def execute_json(self, arguments: dict, variable_stack: list = []) -> Graph: """JSON execution: process arguments and return Graph (same as execute)""" diff --git a/tests/unit/test_process_json_jsonld.py b/tests/unit/test_process_json_jsonld.py new file mode 100644 index 0000000..a85667f --- /dev/null +++ b/tests/unit/test_process_json_jsonld.py @@ -0,0 +1,122 @@ +"""Regression tests for Operation.process_json JSON-LD handling. + +These are implementation-level regression tests (not spec derivations): they +pin how process_json treats JSON-LD-shaped dicts. + +Bug (regressed in 1.3.0): a JSON-LD-shaped dict carrying a reserved key +(@id/@type/@graph/@context) was eagerly parsed to an rdflib.Graph at the +process_json layer. When such a dict embedded {"@op": ...} operations, the +parse happened *before* those ops resolved. Unresolved @op objects are illegal +JSON-LD (an @id must be a string IRI, not an object), so the parser silently +minted blank nodes and the intended IRIs were lost. + +Fix: process_json no longer parses JSON-LD to a Graph at all. It resolves any +embedded @op/variable references in place (leaving the rest as plain JSON) and +returns a dict; the consuming op (POST/PUT/Merge/ldh Create*/Add*) parses it, +which they all already do — and with the correct base IRI. +""" + +from __future__ import annotations + +from rdflib import BNode, Graph, URIRef + +from web_algebra.operation import Operation + +PROV_GENERATED_BY = URIRef("http://www.w3.org/ns/prov#wasGeneratedBy") +FOAF_PRIMARY_TOPIC = URIRef("http://xmlns.com/foaf/0.1/primaryTopic") +DOC_URI = URIRef("https://example.org/doc/") +ACTIVITY_URI = URIRef("https://example.org/activity/#this") +MESSAGE_URI = URIRef("https://example.org/message/#this") + + +class TestOpBearingJsonLd: + """JSON-LD documents with embedded @op must resolve before being parsed.""" + + def test_op_valued_ids_resolve_to_iris_not_bnodes(self, settings): + # An @id and a nested object @id both come from runtime bindings. + # End-to-end through a consuming op (Merge) that parses the document. + json_data = { + "@op": "Merge", + "args": { + "graphs": [ + { + "@id": {"@op": "Value", "args": {"name": "$docUrl"}}, + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + str(FOAF_PRIMARY_TOPIC): { + "@id": {"@op": "Value", "args": {"name": "$message"}} + }, + } + ] + }, + } + stack = [{"docUrl": DOC_URI, "message": MESSAGE_URI}] + + result = Operation.process_json(settings, json_data, {}, stack) + + assert isinstance(result, Graph) + # The document URI must survive as a concrete subject, not a blank node. + assert (DOC_URI, PROV_GENERATED_BY, ACTIVITY_URI) in result + assert (DOC_URI, FOAF_PRIMARY_TOPIC, MESSAGE_URI) in result + # No triple should have a blank-node subject — that was the bug. + assert not any(isinstance(s, BNode) for s, _, _ in result) + + def test_op_valued_id_resolves_in_place(self, settings): + # process_json resolves the embedded op to its RDFLib term and returns + # a dict — it does not parse to a Graph. + json_data = { + "@id": {"@op": "Value", "args": {"name": "$docUrl"}}, + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + } + stack = [{"docUrl": DOC_URI}] + + result = Operation.process_json(settings, json_data, {}, stack) + + assert isinstance(result, dict) + assert result["@id"] == DOC_URI + + def test_pure_data_fragment_inside_document_stays_a_dict(self, settings): + # A nested {"@id": "..."} object reference must NOT be parsed into a + # standalone Graph — it is part of the one enclosing JSON-LD document + # that the consuming op serialises and parses as a whole. + json_data = { + "@id": {"@op": "Value", "args": {"name": "$docUrl"}}, + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + } + stack = [{"docUrl": DOC_URI}] + + result = Operation.process_json(settings, json_data, {}, stack) + + assert not isinstance(result[str(PROV_GENERATED_BY)], Graph) + assert result[str(PROV_GENERATED_BY)] == {"@id": str(ACTIVITY_URI)} + + +class TestPureDataJsonLd: + """Static JSON-LD (no @op) is returned as a dict for the consumer to parse.""" + + def test_pure_data_jsonld_returns_dict(self, settings): + json_data = { + "@id": str(DOC_URI), + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + } + + result = Operation.process_json(settings, json_data) + + # No eager Graph parse: the dict is preserved verbatim for the consumer. + assert isinstance(result, dict) + assert result == json_data + + def test_pure_data_jsonld_parses_to_expected_graph(self, settings): + # Sanity: the preserved dict still yields the intended triples when a + # consuming op parses it (the standard json.dumps -> json-ld parse). + import json + + json_data = { + "@id": str(DOC_URI), + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + } + + result = Operation.process_json(settings, json_data) + graph = Graph() + graph.parse(data=json.dumps(result), format="json-ld") + + assert (DOC_URI, PROV_GENERATED_BY, ACTIVITY_URI) in graph diff --git a/tests/unit/test_to_graph.py b/tests/unit/test_to_graph.py new file mode 100644 index 0000000..53009cd --- /dev/null +++ b/tests/unit/test_to_graph.py @@ -0,0 +1,54 @@ +"""Tests for Operation.to_graph — the single JSON-LD -> rdflib.Graph boundary. + +Convention (implementation-level): operations resolve their arguments to plain +data via process_json, then call to_graph to hand execute() an rdflib.Graph. +execute() always works in rdflib terms; to_graph is the only place JSON-LD is +parsed, and the only place an op-specific base IRI is applied. +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, URIRef + +from web_algebra.operation import Operation + +DOC_URI = URIRef("https://example.org/doc/") +PROV_GENERATED_BY = URIRef("http://www.w3.org/ns/prov#wasGeneratedBy") +ACTIVITY_URI = URIRef("https://example.org/activity/#this") + + +class TestToGraph: + def test_dict_is_parsed_as_jsonld(self): + data = { + "@id": str(DOC_URI), + str(PROV_GENERATED_BY): {"@id": str(ACTIVITY_URI)}, + } + + graph = Operation.to_graph(data) + + assert isinstance(graph, Graph) + assert (DOC_URI, PROV_GENERATED_BY, ACTIVITY_URI) in graph + + def test_graph_passes_through_unchanged(self): + # An upstream op (e.g. CONSTRUCT) already produced a Graph — identity. + g = Graph() + g.add((DOC_URI, PROV_GENERATED_BY, ACTIVITY_URI)) + + assert Operation.to_graph(g) is g + + def test_base_resolves_relative_iris(self): + # A relative @id must resolve against the supplied base IRI. + data = {"@id": "", str(PROV_GENERATED_BY): {"@id": "#this"}} + + graph = Operation.to_graph(data, base="https://example.org/doc/") + + assert ( + URIRef("https://example.org/doc/"), + PROV_GENERATED_BY, + URIRef("https://example.org/doc/#this"), + ) in graph + + def test_non_jsonld_input_raises_typeerror(self): + with pytest.raises(TypeError): + Operation.to_graph("not a graph or dict")