diff --git a/README.md b/README.md index 7861231..2f0bf5b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The operations cover read-write Linked Data, SPARQL queries, URI manipulation, a - `DESCRIBE` - `SELECT` - `Substitute` + - `Values` - `SPARQLString` - Schema - `ExtractClasses` diff --git a/formal-semantics.md b/formal-semantics.md index 1d2e139..485ce09 100644 --- a/formal-semantics.md +++ b/formal-semantics.md @@ -158,6 +158,12 @@ Abstract: Literal × Literal × Term → Literal Python: def execute(self, query: Literal, var: Literal, binding_value: Any) -> Literal ``` +**Values** - Append a VALUES data block from a result set to a SPARQL query +``` +Abstract: Literal × Result × Maybe (Sequence Literal) → Literal +Python: def execute(self, query: Literal, data: Result, vars: Optional[List[str]] = None) -> Literal +``` + **SPARQLString** - Generate SPARQL queries from natural language ``` Abstract: Literal → Literal diff --git a/prompts/system.md b/prompts/system.md index 943252f..fe2a594 100644 --- a/prompts/system.md +++ b/prompts/system.md @@ -913,6 +913,37 @@ CONSTRUCT WHERE { } ``` +## Values(query: str, data: Result, vars: List[str]) -> str + +Appends a SPARQL `VALUES` data block, built from a SPARQL result set, to a query. + +`Values` is the set-valued counterpart of `Substitute`: where `Substitute` injects a single term for a single variable, `Values` injects a whole result set (rows of bindings) as inline data. Use it to constrain or batch one query by the results of another (e.g. a `SELECT`) in a single request, instead of iterating with `ForEach`. + +The block is appended as a trailing `VALUES` clause, which joins with the query's outermost group — the variable names in `data` (or the optional `vars` subset) must match the variables used in the query. Each value is serialized from its RDF term with correct escaping; blank nodes are rejected (they are not allowed in a `VALUES` block). A missing binding in a row is emitted as `UNDEF`. + +### Example JSON + +```json +{ + "@op": "Values", + "args": { + "query": "DESCRIBE ?city WHERE { ?city a }", + "data": { + "@op": "SELECT", + "args": { + "endpoint": "https://dbpedia.org/sparql", + "query": "SELECT ?city WHERE { ?city } LIMIT 2" + } + } + } +} +``` + +Result: +```sparql +DESCRIBE ?city WHERE { ?city a } VALUES ?city { } +``` + ## Concat(inputs: List[str]) -> str Concatenates a list of string inputs into a single string. Useful for building URIs from multiple parts. diff --git a/pyproject.toml b/pyproject.toml index a417f21..35fd510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "web-algebra" -version = "1.4.1" +version = "1.5.0" description = "Composable RDF operations in JSON" readme = "README.md" license = "Apache-2.0" diff --git a/src/web_algebra/operations/sparql/values.py b/src/web_algebra/operations/sparql/values.py new file mode 100644 index 0000000..f1f9cb6 --- /dev/null +++ b/src/web_algebra/operations/sparql/values.py @@ -0,0 +1,153 @@ +from typing import Any, List, Optional +from rdflib import URIRef, Literal, BNode +from rdflib.namespace import XSD +from rdflib.query import Result +from rdflib.term import Node +from mcp import types +from web_algebra.mcp_tool import MCPTool +from web_algebra.operation import Operation +from web_algebra.json_result import JSONResult + + +class Values(Operation, MCPTool): + """ + Appends a SPARQL `VALUES` data block, built from a result set, to a query. + + `Values` is the set-valued counterpart of `Substitute`: where `Substitute` + injects a single term for a single variable, `Values` injects a whole solution + sequence (rows × variables) as inline data. Every cell is serialized from its + RDFLib term (never string-spliced), so IRIs and literals are escaped correctly. + + The block is appended as a trailing `ValuesClause` (`... WHERE { ... } VALUES + ...`), which joins with the query's outermost group. This is the only `VALUES` + position reachable without deconstructing the query into its algebra; interior + placement (inside an OPTIONAL / sub-SELECT) is intentionally not supported. + + Example: Values("DESCRIBE ?s ?o WHERE { ?s ?p ?o }", ) produces + "DESCRIBE ?s ?o WHERE { ?s ?p ?o } VALUES ?s { }". + """ + + @classmethod + def description(cls) -> str: + return """Appends a SPARQL VALUES data block, built from a SPARQL result set, to a query string. This is the set-valued counterpart of Substitute: it injects a whole solution sequence (rows of variable bindings) as inline data instead of a single term, enabling one query to be constrained by, or batched over, the results of another. The block is appended as a trailing VALUES clause that joins with the query's outermost group. Each value is serialized from its RDF term with correct escaping; blank nodes are rejected as they are not permitted in a VALUES block.""" + + @classmethod + def inputSchema(cls) -> dict: + """ + Returns the JSON schema of the operation's input arguments. + """ + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The SPARQL query string to append the VALUES block to. It must not already end with a VALUES clause.", + }, + "data": { + "type": "object", + "description": "A SPARQL result set (as produced by SELECT) whose variables and rows become the VALUES block. The variable names must match those used in the query.", + }, + "vars": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional subset and ordering of variable names to emit as columns. Defaults to all variables of the result set.", + }, + }, + "required": ["query", "data"], + } + + def execute( + self, query: Literal, data: Result, vars: Optional[List[str]] = None + ) -> Literal: + """Pure function: append a VALUES block rendered from `data` to `query`.""" + if not isinstance(query, Literal): + raise TypeError( + f"Values.execute expects query to be Literal, got {type(query)}" + ) + if not isinstance(data, Result): + raise TypeError( + f"Values.execute expects data to be Result, got {type(data)}" + ) + + if vars is not None: + columns = [str(v).lstrip("?") for v in vars] + else: + columns = [str(v) for v in (data.vars or [])] + + # Binding dict keys may be rdflib.Variable (from Graph.query) or str (from + # JSONResult); normalise to plain names for lookup. + rows = [ + {str(k): term for k, term in binding.items()} + for binding in (data.bindings or []) + ] + + block = self._render_values(columns, rows) + return Literal(f"{str(query)} {block}", datatype=XSD.string) + + def _render_values(self, columns: List[str], rows: List[dict]) -> str: + """Render a SPARQL VALUES block from column names and normalised rows.""" + if len(columns) == 1: + col = columns[0] + cells = " ".join(self._format_term(row.get(col)) for row in rows) + return f"VALUES ?{col} {{ {cells} }}" + + header = " ".join(f"?{col}" for col in columns) + tuples = " ".join( + "( " + " ".join(self._format_term(row.get(col)) for col in columns) + " )" + for row in rows + ) + return f"VALUES ({header}) {{ {tuples} }}" + + @staticmethod + def _format_term(term: Optional[Node]) -> str: + """Serialize an RDF term to SPARQL syntax; None becomes UNDEF.""" + if term is None: + return "UNDEF" + if isinstance(term, BNode): + raise ValueError( + "Values: blank nodes are not allowed in a SPARQL VALUES data block" + ) + if not isinstance(term, (URIRef, Literal)): + raise TypeError( + f"Values expects RDF terms (URIRef/Literal) in bindings, got {type(term)}" + ) + # n3() yields correctly-escaped SPARQL syntax: , "lex", "lex"@lang, + # "lex"^^
, and bare numeric/boolean forms. + return term.n3() + + def execute_json(self, arguments: dict, variable_stack: list = []) -> Literal: + """JSON execution: process arguments and delegate to execute().""" + query_data = Operation.process_json( + self.settings, arguments["query"], self.context, variable_stack + ) + query = Operation.json_to_rdflib(query_data) + if not isinstance(query, Literal): + raise TypeError( + f"Values operation expects 'query' to be Literal, got {type(query)}" + ) + + data = Operation.process_json( + self.settings, arguments["data"], self.context, variable_stack + ) + if not isinstance(data, Result): + raise TypeError( + f"Values operation expects 'data' to be Result, got {type(data)}" + ) + + vars = arguments.get("vars") + if vars is not None: + vars_data = Operation.process_json( + self.settings, vars, self.context, variable_stack + ) + vars = [str(v) for v in vars_data] + + return self.execute(query, data, vars) + + def mcp_run(self, arguments: dict, context: Any = None) -> Any: + """MCP execution: plain args → plain results.""" + query = Literal(arguments["query"], datatype=XSD.string) + data = JSONResult.from_json(arguments["data"]) + vars = arguments.get("vars") + + result = self.execute(query, data, vars) + return [types.TextContent(type="text", text=str(result))] diff --git a/tests/unit/test_values.py b/tests/unit/test_values.py new file mode 100644 index 0000000..13ddf82 --- /dev/null +++ b/tests/unit/test_values.py @@ -0,0 +1,125 @@ +"""Spec: formal-semantics.md "Values - Append a VALUES data block to a SPARQL query" +Abstract: Literal × Result × Maybe (Sequence Literal) → Literal +Python: def execute(self, query: Literal, data: Result, vars: Optional[List[str]] = None) -> Literal + +Values renders a trailing SPARQL VALUES block from a result set and appends it to a +query. Tests build a Result with rdflib's Graph.query and assert on the produced +query string. ORDER BY is used wherever row order is asserted, since SPARQL is +otherwise unordered. +""" + +from __future__ import annotations + +import pytest +from rdflib import BNode, Graph, Literal, URIRef + +from web_algebra.operation import Operation + +EX_A = "http://ex/a" +EX_B = "http://ex/b" +EX_P = "http://ex/p" +EX_Q = "http://ex/q" + + +def _op(settings): + return Operation.get("Values")(settings=settings) + + +def _one_var_two_rows(): + g = Graph() + g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1"))) + g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2"))) + return g.query(f"SELECT ?s WHERE {{ ?s <{EX_P}> ?o }} ORDER BY ?s") + + +def _two_var_two_rows(): + g = Graph() + g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1"))) + g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2"))) + return g.query(f"SELECT ?s ?o WHERE {{ ?s <{EX_P}> ?o }} ORDER BY ?s") + + +def _ragged_two_rows(): + # row a has ?x via the OPTIONAL; row b does not -> UNDEF + g = Graph() + g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1"))) + g.add((URIRef(EX_A), URIRef(EX_Q), Literal("x1"))) + g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2"))) + return g.query( + f"SELECT ?s ?x WHERE {{ ?s <{EX_P}> ?o . OPTIONAL {{ ?s <{EX_Q}> ?x }} }} ORDER BY ?s" + ) + + +def _empty(): + g = Graph() + return g.query(f"SELECT ?s WHERE {{ ?s <{EX_P}> ?o }}") + + +def _bnode_value(): + g = Graph() + g.add((URIRef(EX_A), URIRef(EX_P), BNode("b1"))) + return g.query(f"SELECT ?o WHERE {{ <{EX_A}> <{EX_P}> ?o }}") + + +class TestValuesPure: + def test_returns_literal(self, settings): + result = _op(settings).execute(Literal("DESCRIBE ?s WHERE { ?s ?p ?o }"), _one_var_two_rows()) + assert isinstance(result, Literal) + + def test_single_var_short_form(self, settings): + q = "DESCRIBE ?s WHERE { ?s ?p ?o }" + result = str(_op(settings).execute(Literal(q), _one_var_two_rows())) + assert result.startswith(q + " ") + assert result.endswith(f"VALUES ?s {{ <{EX_A}> <{EX_B}> }}") + + def test_multi_var_long_form(self, settings): + result = str(_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _two_var_two_rows())) + assert "VALUES (?s ?o) {" in result + assert f'( <{EX_A}> "v1" )' in result + assert f'( <{EX_B}> "v2" )' in result + + def test_unbound_cell_becomes_undef(self, settings): + result = str(_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _ragged_two_rows())) + assert "VALUES (?s ?x) {" in result + assert f'( <{EX_A}> "x1" )' in result + assert f"( <{EX_B}> UNDEF )" in result + + def test_empty_result_renders_empty_block(self, settings): + result = str(_op(settings).execute(Literal("DESCRIBE ?s WHERE { ?s ?p ?o }"), _empty())) + assert result.endswith("VALUES ?s { }") + + def test_vars_override_selects_and_orders_columns(self, settings): + result = str( + _op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _two_var_two_rows(), ["o", "s"]) + ) + assert "VALUES (?o ?s) {" in result + assert f'( "v1" <{EX_A}> )' in result + + def test_bnode_value_raises(self, settings): + # Blank nodes are not permitted in a SPARQL VALUES data block. + with pytest.raises(ValueError): + _op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _bnode_value()) + + def test_literal_with_quote_is_escaped(self, settings): + # The whole point over Concat: a literal containing a quote must be escaped. + g = Graph() + g.add((URIRef(EX_A), URIRef(EX_P), Literal('a"b'))) + data = g.query(f"SELECT ?o WHERE {{ ?s <{EX_P}> ?o }}") + result = str(_op(settings).execute(Literal("SELECT * WHERE { ?o ?p ?x }"), data)) + assert r"\"" in result # the embedded quote is backslash-escaped + + def test_wrong_query_type_raises(self, settings): + with pytest.raises(TypeError): + _op(settings).execute(URIRef("not-a-query"), _one_var_two_rows()) + + def test_wrong_data_type_raises(self, settings): + with pytest.raises(TypeError): + _op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), [{"s": URIRef(EX_A)}]) + + +class TestValuesJson: + @pytest.mark.skip( + reason="execute_json resolves `data` from a nested SELECT, which requires a live endpoint (network-marked)" + ) + def test_json_dispatch(self, settings): + pass