Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The operations cover read-write Linked Data, SPARQL queries, URI manipulation, a
- `DESCRIBE`
- `SELECT`
- `Substitute`
- `Values`
- `SPARQLString`
- Schema
- `ExtractClasses`
Expand Down
6 changes: 6 additions & 0 deletions formal-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://dbpedia.org/ontology/City> }",
"data": {
"@op": "SELECT",
"args": {
"endpoint": "https://dbpedia.org/sparql",
"query": "SELECT ?city WHERE { ?city <http://dbpedia.org/ontology/country> <http://dbpedia.org/resource/Denmark> } LIMIT 2"
}
}
}
}
```

Result:
```sparql
DESCRIBE ?city WHERE { ?city a <http://dbpedia.org/ontology/City> } VALUES ?city { <http://dbpedia.org/resource/Copenhagen> <http://dbpedia.org/resource/Aarhus> }
```

## Concat(inputs: List[str]) -> str

Concatenates a list of string inputs into a single string. Useful for building URIs from multiple parts.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
153 changes: 153 additions & 0 deletions src/web_algebra/operations/sparql/values.py
Original file line number Diff line number Diff line change
@@ -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 }", <result over ?s>) produces
"DESCRIBE ?s ?o WHERE { ?s ?p ?o } VALUES ?s { <a> <b> }".
"""

@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: <iri>, "lex", "lex"@lang,
# "lex"^^<dt>, 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))]
125 changes: 125 additions & 0 deletions tests/unit/test_values.py
Original file line number Diff line number Diff line change
@@ -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
Loading