Skip to content

Commit 128e184

Browse files
authored
Merge pull request #24 from AtomGraph/feat-values-op
VALUES operation
2 parents 767c425 + 510f4dd commit 128e184

6 files changed

Lines changed: 317 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ The operations cover read-write Linked Data, SPARQL queries, URI manipulation, a
4949
- `DESCRIBE`
5050
- `SELECT`
5151
- `Substitute`
52+
- `Values`
5253
- `SPARQLString`
5354
- Schema
5455
- `ExtractClasses`

formal-semantics.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ Abstract: Literal × Literal × Term → Literal
158158
Python: def execute(self, query: Literal, var: Literal, binding_value: Any) -> Literal
159159
```
160160

161+
**Values** - Append a VALUES data block from a result set to a SPARQL query
162+
```
163+
Abstract: Literal × Result × Maybe (Sequence Literal) → Literal
164+
Python: def execute(self, query: Literal, data: Result, vars: Optional[List[str]] = None) -> Literal
165+
```
166+
161167
**SPARQLString** - Generate SPARQL queries from natural language
162168
```
163169
Abstract: Literal → Literal

prompts/system.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,37 @@ CONSTRUCT WHERE {
913913
}
914914
```
915915

916+
## Values(query: str, data: Result, vars: List[str]) -> str
917+
918+
Appends a SPARQL `VALUES` data block, built from a SPARQL result set, to a query.
919+
920+
`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`.
921+
922+
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`.
923+
924+
### Example JSON
925+
926+
```json
927+
{
928+
"@op": "Values",
929+
"args": {
930+
"query": "DESCRIBE ?city WHERE { ?city a <http://dbpedia.org/ontology/City> }",
931+
"data": {
932+
"@op": "SELECT",
933+
"args": {
934+
"endpoint": "https://dbpedia.org/sparql",
935+
"query": "SELECT ?city WHERE { ?city <http://dbpedia.org/ontology/country> <http://dbpedia.org/resource/Denmark> } LIMIT 2"
936+
}
937+
}
938+
}
939+
}
940+
```
941+
942+
Result:
943+
```sparql
944+
DESCRIBE ?city WHERE { ?city a <http://dbpedia.org/ontology/City> } VALUES ?city { <http://dbpedia.org/resource/Copenhagen> <http://dbpedia.org/resource/Aarhus> }
945+
```
946+
916947
## Concat(inputs: List[str]) -> str
917948

918949
Concatenates a list of string inputs into a single string. Useful for building URIs from multiple parts.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "web-algebra"
3-
version = "1.4.1"
3+
version = "1.5.0"
44
description = "Composable RDF operations in JSON"
55
readme = "README.md"
66
license = "Apache-2.0"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from typing import Any, List, Optional
2+
from rdflib import URIRef, Literal, BNode
3+
from rdflib.namespace import XSD
4+
from rdflib.query import Result
5+
from rdflib.term import Node
6+
from mcp import types
7+
from web_algebra.mcp_tool import MCPTool
8+
from web_algebra.operation import Operation
9+
from web_algebra.json_result import JSONResult
10+
11+
12+
class Values(Operation, MCPTool):
13+
"""
14+
Appends a SPARQL `VALUES` data block, built from a result set, to a query.
15+
16+
`Values` is the set-valued counterpart of `Substitute`: where `Substitute`
17+
injects a single term for a single variable, `Values` injects a whole solution
18+
sequence (rows × variables) as inline data. Every cell is serialized from its
19+
RDFLib term (never string-spliced), so IRIs and literals are escaped correctly.
20+
21+
The block is appended as a trailing `ValuesClause` (`... WHERE { ... } VALUES
22+
...`), which joins with the query's outermost group. This is the only `VALUES`
23+
position reachable without deconstructing the query into its algebra; interior
24+
placement (inside an OPTIONAL / sub-SELECT) is intentionally not supported.
25+
26+
Example: Values("DESCRIBE ?s ?o WHERE { ?s ?p ?o }", <result over ?s>) produces
27+
"DESCRIBE ?s ?o WHERE { ?s ?p ?o } VALUES ?s { <a> <b> }".
28+
"""
29+
30+
@classmethod
31+
def description(cls) -> str:
32+
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."""
33+
34+
@classmethod
35+
def inputSchema(cls) -> dict:
36+
"""
37+
Returns the JSON schema of the operation's input arguments.
38+
"""
39+
return {
40+
"type": "object",
41+
"properties": {
42+
"query": {
43+
"type": "string",
44+
"description": "The SPARQL query string to append the VALUES block to. It must not already end with a VALUES clause.",
45+
},
46+
"data": {
47+
"type": "object",
48+
"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.",
49+
},
50+
"vars": {
51+
"type": "array",
52+
"items": {"type": "string"},
53+
"description": "Optional subset and ordering of variable names to emit as columns. Defaults to all variables of the result set.",
54+
},
55+
},
56+
"required": ["query", "data"],
57+
}
58+
59+
def execute(
60+
self, query: Literal, data: Result, vars: Optional[List[str]] = None
61+
) -> Literal:
62+
"""Pure function: append a VALUES block rendered from `data` to `query`."""
63+
if not isinstance(query, Literal):
64+
raise TypeError(
65+
f"Values.execute expects query to be Literal, got {type(query)}"
66+
)
67+
if not isinstance(data, Result):
68+
raise TypeError(
69+
f"Values.execute expects data to be Result, got {type(data)}"
70+
)
71+
72+
if vars is not None:
73+
columns = [str(v).lstrip("?") for v in vars]
74+
else:
75+
columns = [str(v) for v in (data.vars or [])]
76+
77+
# Binding dict keys may be rdflib.Variable (from Graph.query) or str (from
78+
# JSONResult); normalise to plain names for lookup.
79+
rows = [
80+
{str(k): term for k, term in binding.items()}
81+
for binding in (data.bindings or [])
82+
]
83+
84+
block = self._render_values(columns, rows)
85+
return Literal(f"{str(query)} {block}", datatype=XSD.string)
86+
87+
def _render_values(self, columns: List[str], rows: List[dict]) -> str:
88+
"""Render a SPARQL VALUES block from column names and normalised rows."""
89+
if len(columns) == 1:
90+
col = columns[0]
91+
cells = " ".join(self._format_term(row.get(col)) for row in rows)
92+
return f"VALUES ?{col} {{ {cells} }}"
93+
94+
header = " ".join(f"?{col}" for col in columns)
95+
tuples = " ".join(
96+
"( " + " ".join(self._format_term(row.get(col)) for col in columns) + " )"
97+
for row in rows
98+
)
99+
return f"VALUES ({header}) {{ {tuples} }}"
100+
101+
@staticmethod
102+
def _format_term(term: Optional[Node]) -> str:
103+
"""Serialize an RDF term to SPARQL syntax; None becomes UNDEF."""
104+
if term is None:
105+
return "UNDEF"
106+
if isinstance(term, BNode):
107+
raise ValueError(
108+
"Values: blank nodes are not allowed in a SPARQL VALUES data block"
109+
)
110+
if not isinstance(term, (URIRef, Literal)):
111+
raise TypeError(
112+
f"Values expects RDF terms (URIRef/Literal) in bindings, got {type(term)}"
113+
)
114+
# n3() yields correctly-escaped SPARQL syntax: <iri>, "lex", "lex"@lang,
115+
# "lex"^^<dt>, and bare numeric/boolean forms.
116+
return term.n3()
117+
118+
def execute_json(self, arguments: dict, variable_stack: list = []) -> Literal:
119+
"""JSON execution: process arguments and delegate to execute()."""
120+
query_data = Operation.process_json(
121+
self.settings, arguments["query"], self.context, variable_stack
122+
)
123+
query = Operation.json_to_rdflib(query_data)
124+
if not isinstance(query, Literal):
125+
raise TypeError(
126+
f"Values operation expects 'query' to be Literal, got {type(query)}"
127+
)
128+
129+
data = Operation.process_json(
130+
self.settings, arguments["data"], self.context, variable_stack
131+
)
132+
if not isinstance(data, Result):
133+
raise TypeError(
134+
f"Values operation expects 'data' to be Result, got {type(data)}"
135+
)
136+
137+
vars = arguments.get("vars")
138+
if vars is not None:
139+
vars_data = Operation.process_json(
140+
self.settings, vars, self.context, variable_stack
141+
)
142+
vars = [str(v) for v in vars_data]
143+
144+
return self.execute(query, data, vars)
145+
146+
def mcp_run(self, arguments: dict, context: Any = None) -> Any:
147+
"""MCP execution: plain args → plain results."""
148+
query = Literal(arguments["query"], datatype=XSD.string)
149+
data = JSONResult.from_json(arguments["data"])
150+
vars = arguments.get("vars")
151+
152+
result = self.execute(query, data, vars)
153+
return [types.TextContent(type="text", text=str(result))]

tests/unit/test_values.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Spec: formal-semantics.md "Values - Append a VALUES data block to a SPARQL query"
2+
Abstract: Literal × Result × Maybe (Sequence Literal) → Literal
3+
Python: def execute(self, query: Literal, data: Result, vars: Optional[List[str]] = None) -> Literal
4+
5+
Values renders a trailing SPARQL VALUES block from a result set and appends it to a
6+
query. Tests build a Result with rdflib's Graph.query and assert on the produced
7+
query string. ORDER BY is used wherever row order is asserted, since SPARQL is
8+
otherwise unordered.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
from rdflib import BNode, Graph, Literal, URIRef
15+
16+
from web_algebra.operation import Operation
17+
18+
EX_A = "http://ex/a"
19+
EX_B = "http://ex/b"
20+
EX_P = "http://ex/p"
21+
EX_Q = "http://ex/q"
22+
23+
24+
def _op(settings):
25+
return Operation.get("Values")(settings=settings)
26+
27+
28+
def _one_var_two_rows():
29+
g = Graph()
30+
g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1")))
31+
g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2")))
32+
return g.query(f"SELECT ?s WHERE {{ ?s <{EX_P}> ?o }} ORDER BY ?s")
33+
34+
35+
def _two_var_two_rows():
36+
g = Graph()
37+
g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1")))
38+
g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2")))
39+
return g.query(f"SELECT ?s ?o WHERE {{ ?s <{EX_P}> ?o }} ORDER BY ?s")
40+
41+
42+
def _ragged_two_rows():
43+
# row a has ?x via the OPTIONAL; row b does not -> UNDEF
44+
g = Graph()
45+
g.add((URIRef(EX_A), URIRef(EX_P), Literal("v1")))
46+
g.add((URIRef(EX_A), URIRef(EX_Q), Literal("x1")))
47+
g.add((URIRef(EX_B), URIRef(EX_P), Literal("v2")))
48+
return g.query(
49+
f"SELECT ?s ?x WHERE {{ ?s <{EX_P}> ?o . OPTIONAL {{ ?s <{EX_Q}> ?x }} }} ORDER BY ?s"
50+
)
51+
52+
53+
def _empty():
54+
g = Graph()
55+
return g.query(f"SELECT ?s WHERE {{ ?s <{EX_P}> ?o }}")
56+
57+
58+
def _bnode_value():
59+
g = Graph()
60+
g.add((URIRef(EX_A), URIRef(EX_P), BNode("b1")))
61+
return g.query(f"SELECT ?o WHERE {{ <{EX_A}> <{EX_P}> ?o }}")
62+
63+
64+
class TestValuesPure:
65+
def test_returns_literal(self, settings):
66+
result = _op(settings).execute(Literal("DESCRIBE ?s WHERE { ?s ?p ?o }"), _one_var_two_rows())
67+
assert isinstance(result, Literal)
68+
69+
def test_single_var_short_form(self, settings):
70+
q = "DESCRIBE ?s WHERE { ?s ?p ?o }"
71+
result = str(_op(settings).execute(Literal(q), _one_var_two_rows()))
72+
assert result.startswith(q + " ")
73+
assert result.endswith(f"VALUES ?s {{ <{EX_A}> <{EX_B}> }}")
74+
75+
def test_multi_var_long_form(self, settings):
76+
result = str(_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _two_var_two_rows()))
77+
assert "VALUES (?s ?o) {" in result
78+
assert f'( <{EX_A}> "v1" )' in result
79+
assert f'( <{EX_B}> "v2" )' in result
80+
81+
def test_unbound_cell_becomes_undef(self, settings):
82+
result = str(_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _ragged_two_rows()))
83+
assert "VALUES (?s ?x) {" in result
84+
assert f'( <{EX_A}> "x1" )' in result
85+
assert f"( <{EX_B}> UNDEF )" in result
86+
87+
def test_empty_result_renders_empty_block(self, settings):
88+
result = str(_op(settings).execute(Literal("DESCRIBE ?s WHERE { ?s ?p ?o }"), _empty()))
89+
assert result.endswith("VALUES ?s { }")
90+
91+
def test_vars_override_selects_and_orders_columns(self, settings):
92+
result = str(
93+
_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _two_var_two_rows(), ["o", "s"])
94+
)
95+
assert "VALUES (?o ?s) {" in result
96+
assert f'( "v1" <{EX_A}> )' in result
97+
98+
def test_bnode_value_raises(self, settings):
99+
# Blank nodes are not permitted in a SPARQL VALUES data block.
100+
with pytest.raises(ValueError):
101+
_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), _bnode_value())
102+
103+
def test_literal_with_quote_is_escaped(self, settings):
104+
# The whole point over Concat: a literal containing a quote must be escaped.
105+
g = Graph()
106+
g.add((URIRef(EX_A), URIRef(EX_P), Literal('a"b')))
107+
data = g.query(f"SELECT ?o WHERE {{ ?s <{EX_P}> ?o }}")
108+
result = str(_op(settings).execute(Literal("SELECT * WHERE { ?o ?p ?x }"), data))
109+
assert r"\"" in result # the embedded quote is backslash-escaped
110+
111+
def test_wrong_query_type_raises(self, settings):
112+
with pytest.raises(TypeError):
113+
_op(settings).execute(URIRef("not-a-query"), _one_var_two_rows())
114+
115+
def test_wrong_data_type_raises(self, settings):
116+
with pytest.raises(TypeError):
117+
_op(settings).execute(Literal("SELECT * WHERE { ?s ?p ?o }"), [{"s": URIRef(EX_A)}])
118+
119+
120+
class TestValuesJson:
121+
@pytest.mark.skip(
122+
reason="execute_json resolves `data` from a nested SELECT, which requires a live endpoint (network-marked)"
123+
)
124+
def test_json_dispatch(self, settings):
125+
pass

0 commit comments

Comments
 (0)