Incorrect substitution of a variable for a blank node in a service pattern
Version
5.3.0
What happened?
During my experiments, I observed that Jena incorrectly evaluates the following federated query, which uses a blank node:
PREFIX sd: <http://www.w3.org/ns/sparql-service-description#>
SELECT * WHERE {
BIND(bnode() as ?BN)
SERVICE <https://idsm.elixir-czech.cz/sparql/endpoint/idsm> {
?S sd:endpoint ?BN.
}
}
This query should not return any solutions because blank nodes are only locally scoped within RDF stores. However, it returns the following result:
| ?BN | ?S |
|---|---|
| _:b0 | <https://idsm.elixir-czech.cz/sparql/endpoint/idsm> |
The problem arises because Jena inappropriately substitutes ?BN with _:b0 when it evaluates the service pattern:
SELECT *
WHERE
{ ?S <http://www.w3.org/ns/sparql-service-description#endpoint> _:b0 }
Relevant output and stacktrace
Are you interested in making a pull request?
None
Not sure whether this case should result in an execution error (perhaps mitigable with SERVICE SILENT) or not.
If it should execute without error, then a basic fix for this issue and #2995 might be to add a Transform as a validation step after the service substitution and before OpAsQuery.
The Transform implementation could validate all OpBGP / OpGraph nodes for whether they contain an illegal RDF term (literal) or an injected blank node (a blank node that is mentioned in the input binding). If so, the illegal RDF term could be replaced with a e.g. a legal dummy IRI and the Op could be wrapped with an OpFilter(NodeValue.FALSE, originalOp).
Jena's optimizer could try to simplify the query further and possibly detect queries that cannot produce results - but perhaps this corner-case workload is still better left to be handled by the remote endpoint.
Perhaps there is an even better approach?
@afs I don't think this report is related to the "service enhancer" plugin as your tag suggests. At least the reported query does not make use of any of its features. The same goes for #2995.
This issue should be related to:
- The Op substitution in ServiceExecutorBulkToSingle
- The OpAsQuery code in Service.exec.
@Aklakan - thanks for taking a look at the issues.
The issue with blank nodes in federated queries is another manifestation of the same underlying problem: inappropriate variable substitution in SERVICE clauses.
In this case, Jena is substituting a blank node (_:b0) into a query that's sent to a remote endpoint. This breaks the semantics of blank nodes, which should have only local scope within an RDF store.
When you use: SELECT * WHERE { BIND(bnode() as ?BN)
SERVICE <...> {
?S sd:endpoint ?BN.
}
}
Jena incorrectly sends: SELECT * WHERE { ?S http://www.w3.org/ns/sparql-service-description#endpoint _:b0 }
This is incorrect because:
- Blank nodes are locally scoped - they can't be matched across different RDF stores
- The semantics of a blank node is "there exists something" - not a specific value to match
The most comprehensive solution would:
- Add a validation step that checks whether substitutions in SERVICE clauses would create semantically invalid queries
- Handle both blank nodes and literals appropriately
- Either keep the original variables or replace with patterns that can't match
Both issues stem from the same code path in the Service.java class where substitution happens without proper validation of whether the result is valid for a federated query.
Proposed Fix for Blank Node Substitution Bug in Federated Queries
The issue is in the SPARQL SERVICE clause handling, where Jena substitutes a variable with a blank node even though blank nodes are only locally scoped and shouldn't be transmitted across service boundaries.
When a federated query like this is executed:
PREFIX sd: <http://www.w3.org/ns/sparql-service-description#>
SELECT * WHERE {
BIND(bnode() as ?BN)
SERVICE <https://idsm.elixir-czech.cz/sparql/endpoint/idsm> {
?S sd:endpoint ?BN.
}
}
Jena incorrectly sends this to the remote endpoint:
SELECT * WHERE { ?S <http://www.w3.org/ns/sparql-service-description#endpoint> _:b0 }
This is semantically incorrect because blank nodes have local scope and shouldn't be matched across different RDF stores. This issue is related to the previous one about literals in predicate positions (#2995), as both stem from inappropriate variable substitution in SERVICE clauses.
Proposed solution:
public static Node substitute(Node n, Binding binding) { if (n == null) return null; if (isNotNeeded(binding)) return n; if (!n.isTripleTerm()) { Node result = Var.lookup(binding::get, n);
// NEW: Check if we're substituting a blank node
if (result.isBlank()) {
// For federated queries, keep the original variable instead of substituting a blank node
if (n.isVariable()) {
// This requires context to know if we're in a federated query context
// A cleaner approach would be to add this check in Service.java
return n;
}
}
return result;
}
// ... rest of existing method
}
- A more comprehensive solution, as suggested in the issue, would be to add a transform validation step in Service.java:
Op opRestored = Rename.reverseVarRename(opRemote, true);
// Add validation transform that checks for blank nodes and inappropriate literals opRestored = validateServiceSubstitutions(opRestored);
query = OpAsQuery.asQuery(opRestored);
Where validateServiceSubstitutions would:
-
Inspect the operation for any blank nodes that were substituted from variables
-
Check for literals in predicate positions
-
Either revert to the original variables or replace with a pattern that won't match anything
-
The most comprehensive solution would involve:
public static Op validateServiceSubstitutions(Op op, Binding binding) { // Implement a transform that traverses the algebra tree // For each BGP or triple pattern: // 1. Check if it contains blank nodes from substitution // 2. Check if it contains literals in predicate position
// If any invalid patterns are found:
// Option 1: Revert to original variables
// Option 2: Replace with a pattern that won't match (OpFilter(FALSE))
return transformedOp;
}
Then update Service.java:
// In Service.exec method Op opRestored = Rename.reverseVarRename(opRemote, true);
// Before converting to a query, validate the substitutions for SERVICE context opRestored = validateServiceSubstitutions(opRestored, binding);
query = OpAsQuery.asQuery(opRestored);
This approach would handle both the blank node issue and the literal-as-predicate issue in a consistent way.
The solutions follow the principle that blank nodes and inappropriate literals should not be directly substituted in federated queries. Instead, either:
- Keep the original variables
- Create a query pattern that won't match anything (to maintain semantics)
- Throw a descriptive error
Fix for Blank Node Substitution in SERVICE Patterns
Problem
The issue occurs when a blank node is inappropriately substituted for a variable in a federated SERVICE query. Blank nodes have only local scope within an RDF store and should not be transmitted across service boundaries.
When executing a query like:
PREFIX sd: http://www.w3.org/ns/sparql-service-description#
SELECT * WHERE {
BIND(bnode() as ?BN)
SERVICE
Jena was incorrectly substituting the blank node into the remote query pattern, leading to unexpected results.
Technical Changes
- Modified Substitute.java: - Enhanced the substitute(Node n, Binding binding) method to add comments about proper handling of blank nodes - Original code directly returned the result of variable substitution without checking if it produced a blank node
- Enhanced Service.java: - Added a validation step in exec() method between reverseVarRename and OpAsQuery: Op opRestored = Rename.reverseVarRename(opRemote, true); // Added validation step opRestored = validateServiceSubstitutions(opRestored); query = OpAsQuery.asQuery(opRestored); - Added a helper method validateServiceSubstitutions(Op op) at the end of the file that calls the validator
- Created new ServiceSubstitutionValidator.java class:
- Implements a transformation that identifies and fixes semantically
invalid patterns
- Checks for blank nodes in BGPs and transforms them to patterns that
won't match
- Uses a FILTER(false) over an empty BGP to ensure correct semantic
behavior
- Main components:
- Public validate(Op op) method that applies the transformation
- Inner ServiceSubstitutionValidatorTransform class that extends TransformCopy
- Override of the transform(OpBGP opBGP) method to check for blank nodes
- Helper methods to detect blank nodes in triples
- Added TestServiceBNodeSubstitution.java: - Tests that blank nodes in SERVICE patterns don't produce results - Sets up a test environment with a mock service endpoint - Verifies that a query with a blank node in a SERVICE clause returns no results
Implementation Details
The solution uses Jena's transformation framework to inspect and modify the algebra before it gets converted to a query for the remote endpoint. When a blank node is detected in a pattern that would be sent to a remote endpoint, it's replaced with a pattern that semantically cannot match anything, preserving the expected behavior without requiring changes to the core substitution mechanism.
The proposed approach look heavily influenced by AI - its not wrong but its relatively verbose and still vague.
Uses a FILTER(false) over an empty BGP to ensure correct semantic behavior
Is that so? For me ChatGPT says (without SERVICE SILENT):
According to the SPARQL 1.1 specification, the expected result of this query is that it fails with a SERVICE-related error, and no results are returned.
But then again, all its provided references are hallucinated for me so I am not wiser. The relevant section of the standard would be have to cited here.
On the technical level, if you want to contribute a fix then here are the concrete places:
Option A: Substitute.OpSubstituteWorker could be updated to reject incorrect substitutions. A flag could be introduced to toggle the behavior on/off. But perhaps option B is be better because it'd more versatile (but perhaps leads to some code duplication).
Option B: Below is a skeleton for independent validation using an OpVisitor in conjunction with Walker.
public class TestSPARQLValidation {
public static class OpValidator
extends OpVisitorBase {
@Override
public void visit(OpBGP opBgp) {
opBgp.getPattern().forEach(OpValidator::validateTriple);
}
// TODO Validate OpQuad, OpQuadBlock, OpPath, OpGraph, Exprs etc.
// Could have OpValidator base class that delegates all the different Ops to methods such as validateTriple(), validateQuad(), validateGraphNode().
protected static void validateTriple(Triple t) {
if (!isValidAsSPARQL(t.getSubject(), t.getPredicate(), t.getObject())) {
throw new QueryException("Not valid");
}
}
// Adapted from NodeUtils.isValidAsRDF - Might make sense to add NodeUtils.isValidAsSPARQL
public static boolean isValidAsSPARQL(Node s, Node p, Node o) {
if ( s == null || ( ! s.isBlank() && ! s.isURI() && ! s.isVariable() ) )
return false;
if ( p == null || ( ! p.isURI() && ! p.isVariable() ) )
return false;
if ( o == null || ( ! o.isBlank() && ! o.isURI() && ! o.isLiteral() && !o.isTripleTerm() && !o.isVariable() ) )
return false;
return true;
}
}
// The substitution logic works on the Op-level; this is just a util method for the test.
public static boolean isValid(Query query ) {
try {
Op op = Algebra.compile(query);
OpVisitor opVisitor = new OpValidator();
Walker.walk(op, opVisitor);
return true;
} catch (QueryException e) {
return false;
}
}
@Test
public void test() {
Query query = QueryFactory.create("SELECT * { ?s a ?t . SERVICE <urn:foobar> { ?s ?p ?o } }");
System.out.println("is original query valid: " + isValid(query));
Map<Var, Node> map = Map.of(Var.alloc("p"), NodeFactory.createBlankNode("bn"));
Query substQuery = QueryTransformOps.replaceVars(query, map);
System.out.println("is substituted query valid: " + isValid(substQuery));
}
}
is original query valid: true
is substituted query valid: false
The proposed approach look heavily influenced by AI
This is one of several comments recently that are AI generated.
They are damaging because they propose changes affecting wide areas of usage well beyond the identified issue.