From 5cead97450180a1f8cec770dceb8c5fb2eb75ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 30 Jun 2026 13:35:00 +0200 Subject: [PATCH 1/2] Add Values operation: append a VALUES data block to a SPARQL query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values is the set-valued counterpart of Substitute. Where Substitute injects one term for one variable, Values injects a whole result set (rows of bindings) into a query as a trailing SPARQL VALUES block, so one query can be constrained or batched by the results of another (e.g. SELECT -> Values -> DESCRIBE) in a single request instead of iterating with ForEach. - src/web_algebra/operations/sparql/values.py: execute(query, data, vars?) takes the query Literal plus an rdflib Result (carries the ordered .vars the block header needs), renders short form for one column and long form for many, emits UNDEF for unbound cells, and serializes every term via term.n3() for correct escaping. Blank nodes are rejected (illegal in a VALUES block). Auto-registers via the operations package walk. - Placement is the trailing ValuesClause only (joins the outermost group) — the only VALUES position reachable without deconstructing the query into its algebra. Interior/scoped placement is intentionally out of scope. - tests/unit/test_values.py: short/long form, UNDEF, empty result, vars subset/reorder, bnode rejection, literal escaping, type errors. - Document in prompts/system.md, formal-semantics.md, README op index. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + formal-semantics.md | 6 + prompts/system.md | 31 ++++ src/web_algebra/operations/sparql/values.py | 153 ++++++++++++++++++++ tests/unit/test_values.py | 125 ++++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 src/web_algebra/operations/sparql/values.py create mode 100644 tests/unit/test_values.py 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/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 From 510f4ddb80c2c7c341da474561463110cb4d511a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 30 Jun 2026 13:35:00 +0200 Subject: [PATCH 2/2] Bump version to 1.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"