|
| 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))] |
0 commit comments