From dbac7a2fe85d49571ee12590e36b24e9ce53e891 Mon Sep 17 00:00:00 2001
From: "AD\\aabdoun"
Date: Wed, 23 Sep 2026 10:10:25 +0200
Subject: [PATCH 1/3] fix(harness): extend numeric normalization to MIN/MAX
aggregates and guard missing manifest entries
---
src/main/antlr/SparqlLexer.g4 | 2 +-
.../impl/sparql/bridge/AstBackedExpr.java | 16 +++++
.../sparql/bridge/ConstructQueryCompiler.java | 9 ++-
.../sparql/bridge/NativeValueComparison.java | 12 ++++
.../impl/sparql/bridge/NextFilterFromAst.java | 8 +--
.../sparql/bridge/SparqlTermResolver.java | 12 +++-
.../impl/sparql/bridge/WhereCompiler.java | 11 +++
.../execution/NextSparqlPipelineExecutor.java | 33 +++++++--
.../impl/sparql/parser/SparqlTermBuilder.java | 42 +++++++++++-
.../listener/ConstructQueryAstListener.java | 68 +++++++++++++++++--
10 files changed, 190 insertions(+), 23 deletions(-)
diff --git a/src/main/antlr/SparqlLexer.g4 b/src/main/antlr/SparqlLexer.g4
index fdb951589..e6fd83e59 100644
--- a/src/main/antlr/SparqlLexer.g4
+++ b/src/main/antlr/SparqlLexer.g4
@@ -171,7 +171,7 @@ PIPE : '|';
// MISC
-IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020'))* '>';
+IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020') | UCHAR)* '>';
PNAME_NS: PN_PREFIX? ':';
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
index 4a76fa6af..4328acfff 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
@@ -22,12 +22,20 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
/** Immutable KGRAM expression backed directly by a Corese-next AST term. */
public final class AstBackedExpr implements Expr {
private final TermAst source;
private final WhereCompiler whereCompiler;
+ /**
+ * Snapshot of the enclosing group's in-scope variables, captured at filter
+ * creation time so that {@link NextFilterFromAst#getVariables()} reports the
+ * correct scheduling dependencies even after {@code WhereCompiler} has moved
+ * to a different scope (e.g., after returning from a MINUS body).
+ */
+ private final Set capturedInScope;
private final NextFilterFromAst filterView;
private int index = ExprType.UNBOUND;
private int subtype = ExprType.GLOBAL;
@@ -41,6 +49,9 @@ public AstBackedExpr(TermAst source) {
AstBackedExpr(TermAst source, WhereCompiler whereCompiler) {
this.source = Objects.requireNonNull(source, "source");
this.whereCompiler = whereCompiler;
+ this.capturedInScope = (whereCompiler != null)
+ ? Set.copyOf(whereCompiler.inScopeVariables())
+ : Set.of();
this.operator = operator(source);
this.filterView = new NextFilterFromAst(this);
}
@@ -53,6 +64,11 @@ WhereCompiler whereCompiler() {
return whereCompiler;
}
+ /** Returns the in-scope variables as they were when this expression was compiled. */
+ Set capturedInScope() {
+ return capturedInScope;
+ }
+
@Override
public Filter getFilter() {
return filterView;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
index cb7f06cbb..19098eebf 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
@@ -76,15 +76,18 @@ private static Exp compileConstructTemplate(
/**
* Resolves a template term to a runtime {@link Node}. A variable reuses the body node when it is
* bound by the {@code WHERE}; otherwise it stays a fresh node (an unbound template variable is
- * valid SPARQL and simply skips its triple at instantiation, so this does not throw). IRIs, blank
- * nodes and literals become fresh constant nodes.
+ * valid SPARQL and simply skips its triple at instantiation, so this does not throw). Blank node
+ * labels become existential (blank-variable) nodes that generate a fresh blank node per solution,
+ * as required by the SPARQL specification. IRIs and literals become fresh constant nodes.
*/
private static Node constructNode(Query query, TermAst term, WhereCompiler compiler) {
if (term instanceof VarAst(String name)) {
Node bound = visibleBodyNode(query, name);
return bound != null ? bound : NodeImpl.forVariable(name);
}
- return compiler.termResolver().toNode(term);
+ // toPatternNode treats blank-node labels as existential variables (fresh per solution),
+ // while toNode would create constant blank nodes shared across all solutions.
+ return compiler.termResolver().toPatternNode(term);
}
private static Node visibleBodyNode(Query query, String name) {
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
index c381b0e31..d902e7a1e 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
@@ -46,6 +46,11 @@ static boolean valueEquals(DatatypeValue left, DatatypeValue right) {
if (isComparableCalendar(first, second)) {
return compareLiterals(first, second) == 0;
}
+ // Two calendar-type literals with different XSD types (e.g., xsd:date vs xsd:dateTime)
+ // are observably different RDF terms — different datatype IRIs imply they cannot be equal.
+ if (isCalendarType(first) && isCalendarType(second)) {
+ return false;
+ }
throw new QueryTypeErrorException("RDF literal values are not equality-comparable");
}
@@ -118,6 +123,13 @@ private static boolean isComparableCalendar(Literal left, Literal right) {
};
}
+ private static boolean isCalendarType(Literal literal) {
+ return literal.getCoreDatatype() instanceof XSDDatatype datatype && switch (datatype) {
+ case DATE, DATETIME, TIME -> true;
+ default -> false;
+ };
+ }
+
private static QueryTypeErrorException incomparable() {
return new QueryTypeErrorException(
"RDF values are not order-comparable in a SPARQL expression");
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
index e04c3da3d..031e15528 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
@@ -24,13 +24,13 @@ public final class NextFilterFromAst implements Filter {
@Override
public List getVariables() {
// Scheduling dependencies include variables inside EXISTS patterns (including
- // GRAPH names and inner FILTERs) only if they are bound in the enclosing query scope.
+ // GRAPH names and inner FILTERs) only if they are bound in the enclosing group scope.
+ // The scope is captured at filter creation time so that nested scopes (e.g. inside
+ // a MINUS body) are used rather than the outer WHERE scope.
// Purely local/existential variables of EXISTS must not be declared, so they do not
// cause postponement of the filter in OPTIONAL patterns (KGRAM Exp.optional/simpleBind).
Set names = new LinkedHashSet<>();
- Set inScope = (owner.whereCompiler() != null)
- ? owner.whereCompiler().inScopeVariables()
- : Set.of();
+ Set inScope = owner.capturedInScope();
boolean recExist = owner.isRecExist();
owner.sourceAst().orElseThrow().accept(new AbstractAstVisitor() {
@Override
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
index 60155b40e..130639c12 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
@@ -14,6 +14,8 @@
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst;
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@@ -206,8 +208,14 @@ private static int appendEscaped(StringBuilder result, String value, int escapeI
if (escape == 'u' || escape == 'U') {
int length = escape == 'u' ? 4 : 8;
if (escapeIndex + 1 + length <= value.length()) {
- result.appendCodePoint(Integer.parseInt(
- value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16));
+ int codePoint = Integer.parseInt(
+ value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16);
+ if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+ throw new QuerySyntaxException(
+ "Surrogate code point U+" + Integer.toHexString(codePoint).toUpperCase()
+ + " is not a valid Unicode scalar value in a SPARQL escape sequence");
+ }
+ result.appendCodePoint(codePoint);
return escapeIndex + 1 + length;
}
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
index 271bfd182..548f323e9 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
@@ -32,6 +32,8 @@
import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
import fr.inria.corese.core.next.query.impl.engine.solution.Mappings;
+import fr.inria.corese.core.next.query.impl.sparql.parser.semantic.support.VariableScopeAnalyzer;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
@@ -141,7 +143,16 @@ case OptionalAst(PatternAst ast) -> {
}
case MinusAst(GroupGraphPatternAst pattern) -> {
Exp left = body;
+ // Use MINUS body scope so EXISTS filters inside see local variables
+ // (e.g. ?x from "?s1 :member ?x" inside the MINUS body) and are
+ // scheduled after those variables are bound, not before.
+ Set outerScope = inScopeVariables;
+ if (!outerScope.isEmpty()) {
+ inScopeVariables = Set.copyOf(
+ new VariableScopeAnalyzer().collectVisibleVariables(pattern));
+ }
Exp right = compile(pattern);
+ inScopeVariables = outerScope;
Exp minusExp = Exp.create(Type.MINUS, left, right);
body = Exp.create(Type.AND);
body.add(minusExp);
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
index b30b1c76a..4d7871cf8 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
@@ -38,12 +38,15 @@
import fr.inria.corese.core.next.storage.api.StorageManager;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
/**
* Internal orchestrator for the Corese-next SPARQL query path.
@@ -296,6 +299,9 @@ private Mappings evaluateWithTimeout(Eval eval, Query kgramQuery, Mapping initia
* Triples where any component is unbound or cannot be converted to a valid
* RDF term are silently skipped, matching standard SPARQL CONSTRUCT semantics.
*/
+ /** Counter for generating unique blank node labels in CONSTRUCT output. */
+ private static final AtomicLong CONSTRUCT_BNODE_COUNTER = new AtomicLong();
+
private List buildConstructStatements(Query kgramQuery, Mappings mappings) {
List templateEdges = new ArrayList<>();
Exp constructTemplate = kgramQuery.getConstruct();
@@ -308,10 +314,12 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
List statements = new ArrayList<>();
for (Mapping mapping : mappings) {
+ // Fresh blank node map per solution: blank-variable label → constant blank node
+ Map blankNodeMap = new HashMap<>();
for (Edge templateEdge : templateEdges) {
- Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping);
- Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping);
- Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping);
+ Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping, blankNodeMap);
+ Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping, blankNodeMap);
+ Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping, blankNodeMap);
if (subjectNode == null || predicateNode == null || objectNode == null) {
continue;
@@ -330,16 +338,27 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
}
/**
- * Resolves a CONSTRUCT template node: if the node is a variable, look it up in
- * the current mapping; if it is already a constant, return it directly.
+ * Resolves a CONSTRUCT template node:
+ *
+ * - Blank-variable nodes (existential blank nodes like {@code _:b0}) are
+ * resolved to a per-solution constant blank node, so that every occurrence
+ * of the same label within one solution maps to the same blank node.
+ * - Regular variable nodes are looked up in the current mapping.
+ * - Constant nodes are returned as-is.
+ *
*
- * @return the bound or constant node, or {@code null} when a variable is unbound
+ * @return the resolved node, or {@code null} when a regular variable is unbound
*/
- private Node resolveTemplateNode(Node templateNode, Mapping mapping) {
+ private Node resolveTemplateNode(Node templateNode, Mapping mapping, Map blankNodeMap) {
if (templateNode == null) {
return null;
}
if (templateNode.isVariable()) {
+ if (templateNode.isBlank()) {
+ // Existential blank node: fresh blank per solution, same label → same node
+ return blankNodeMap.computeIfAbsent(templateNode.getLabel(),
+ label -> NodeImpl.forBlank("cb" + CONSTRUCT_BNODE_COUNTER.getAndIncrement()));
+ }
return mapping.getNode(templateNode);
}
return templateNode;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
index ebe72c83c..d19f2c645 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
@@ -4,6 +4,7 @@
import fr.inria.corese.core.next.data.api.vocabulary.XSD;
import fr.inria.corese.core.next.generated.antlr.SparqlParser;
import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException;
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
import fr.inria.corese.core.next.query.impl.sparql.ast.IriAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.LiteralAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
@@ -91,9 +92,46 @@ public IriAst iri(String raw) {
*/
public LiteralAst literal(String lexical, String lang, String datatype) {
if (lexical == null) throw new IllegalArgumentException("Literal lexical is null");
+ validateNoSurrogateUchars(lexical);
return new LiteralAst(lexical, lang, datatype);
}
+ /**
+ * Scans a raw literal lexical form (including surrounding quotes) for
+ * escape sequences that encode Unicode surrogate code points (U+D800–U+DFFF),
+ * which are illegal in SPARQL and XML.
+ *
+ * @throws QuerySyntaxException if a surrogate code point is found
+ */
+ private static void validateNoSurrogateUchars(String lexical) {
+ int i = 0;
+ while (i < lexical.length()) {
+ char c = lexical.charAt(i);
+ if (c == '\\' && i + 1 < lexical.length()) {
+ char next = lexical.charAt(i + 1);
+ int hexLen = 0;
+ if (next == 'u') hexLen = 4;
+ else if (next == 'U') hexLen = 8;
+ if (hexLen > 0 && i + 2 + hexLen <= lexical.length()) {
+ String hex = lexical.substring(i + 2, i + 2 + hexLen);
+ try {
+ int codePoint = Integer.parseInt(hex, 16);
+ if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+ throw new QuerySyntaxException(
+ "Surrogate code point U+" + hex.toUpperCase()
+ + " is not a valid Unicode scalar value in a SPARQL escape sequence");
+ }
+ } catch (NumberFormatException ignored) {
+ // Not a valid hex sequence; ANTLR grammar ensures this won't happen for UCHAR
+ }
+ i += 2 + hexLen;
+ continue;
+ }
+ }
+ i++;
+ }
+ }
+
// --- ANTLR-context converters ---
public TermAst termFromVar(SparqlParser.Var_Context ctx) {
@@ -235,8 +273,8 @@ public TermAst termFromGraphTerm(SparqlParser.GraphTermContext ctx) {
return blankNodeResolver.apply(ctx.blankNode());
}
if (ctx.NIL() != null) {
- return iri("()");
- } // NIL = () in SPARQL
+ return iri("<" + RDF.nil.getIRI().stringValue() + ">");
+ } // NIL = rdf:nil
return iri(ctx.getText());
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
index 1b3dddb88..1a5cc674d 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
@@ -68,11 +68,71 @@ public void exitTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx) {
if (!inConstructTriples && !inConstructWhere) {
return;
}
- if (ctx.varOrTerm() == null || ctx.propertyListNotEmpty() == null) {
- return;
+ if (ctx.varOrTerm() != null && ctx.propertyListNotEmpty() != null) {
+ TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
+ addConstructProperties(subject, ctx.propertyListNotEmpty(), inConstructWhere);
+ } else if (ctx.triplesNode() != null) {
+ TermAst subject = subjectFromTriplesNode(ctx.triplesNode(), inConstructWhere);
+ if (ctx.propertyList() != null && ctx.propertyList().propertyListNotEmpty() != null) {
+ addConstructProperties(subject, ctx.propertyList().propertyListNotEmpty(), inConstructWhere);
+ }
+ }
+ }
+
+ /**
+ * Expands a {@code triplesNode} into the CONSTRUCT template (and optionally the WHERE BGP),
+ * returning the head term (an anonymous blank node for blank node property lists).
+ */
+ private TermAst subjectFromTriplesNode(SparqlParser.TriplesNodeContext ctx, boolean inConstructWhere) {
+ if (ctx.blankNodePropertyList() != null) {
+ TermAst blankNode = queryBuilder().newAnonymousBlankNode();
+ var inner = ctx.blankNodePropertyList().propertyListNotEmpty();
+ if (inner != null) {
+ addConstructProperties(blankNode, inner, inConstructWhere);
+ }
+ return blankNode;
+ }
+ if (ctx.collection() != null) {
+ return subjectFromCollection(ctx.collection(), inConstructWhere);
}
- TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
- var propertyList = ctx.propertyListNotEmpty();
+ return queryBuilder().iri(ctx.getText());
+ }
+
+ /**
+ * Expands an RDF collection {@code (e1 e2 ...)} into rdf:first/rdf:rest chains
+ * in the CONSTRUCT template and returns the head blank node.
+ */
+ private TermAst subjectFromCollection(SparqlParser.CollectionContext ctx, boolean inConstructWhere) {
+ var nodes = ctx.graphNode();
+ if (nodes.isEmpty()) {
+ return queryBuilder().iri("");
+ }
+ TermAst first = queryBuilder().iri("");
+ TermAst rest = queryBuilder().iri("");
+ TermAst nil = queryBuilder().iri("");
+ TermAst head = queryBuilder().newAnonymousBlankNode();
+ TermAst current = head;
+ for (int i = 0; i < nodes.size(); i++) {
+ TermAst element = queryBuilder().termFromGraphNode(nodes.get(i));
+ queryBuilder().addConstructTriple(current, first, element);
+ if (inConstructWhere) queryBuilder().addTriple(current, first, element);
+ if (i == nodes.size() - 1) {
+ queryBuilder().addConstructTriple(current, rest, nil);
+ if (inConstructWhere) queryBuilder().addTriple(current, rest, nil);
+ } else {
+ TermAst next = queryBuilder().newAnonymousBlankNode();
+ queryBuilder().addConstructTriple(current, rest, next);
+ if (inConstructWhere) queryBuilder().addTriple(current, rest, next);
+ current = next;
+ }
+ }
+ return head;
+ }
+
+ private void addConstructProperties(
+ TermAst subject,
+ SparqlParser.PropertyListNotEmptyContext propertyList,
+ boolean inConstructWhere) {
for (int verbIndex = 0; verbIndex < propertyList.verb().size(); verbIndex++) {
TermAst predicate = queryBuilder().termFromVerb(propertyList.verb(verbIndex));
List objects = queryBuilder().termListFromObjectList(propertyList.objectList(verbIndex));
From 5def890950d3cceda39504c628edcd0d2299c8ca Mon Sep 17 00:00:00 2001
From: "AD\\aabdoun"
Date: Wed, 23 Sep 2026 10:10:25 +0200
Subject: [PATCH 2/3] 609 fix(harness): extend numeric normalization to MIN/MAX
aggregates and guard missing manifest entries
---
src/main/antlr/SparqlLexer.g4 | 2 +-
.../impl/sparql/bridge/AstBackedExpr.java | 16 ++
.../sparql/bridge/ConstructQueryCompiler.java | 9 +-
.../sparql/bridge/NativeValueComparison.java | 12 ++
.../impl/sparql/bridge/NextFilterFromAst.java | 8 +-
.../sparql/bridge/SparqlTermResolver.java | 12 +-
.../impl/sparql/bridge/WhereCompiler.java | 11 ++
.../execution/NextSparqlPipelineExecutor.java | 33 +++-
.../impl/sparql/parser/SparqlTermBuilder.java | 42 ++++-
.../listener/ConstructQueryAstListener.java | 68 +++++++-
.../NativeValueComparisonCalendarTest.java | 73 ++++++++
.../execution/ConstructBlankNodesTest.java | 161 ++++++++++++++++++
.../parser/SparqlParserBnodeBgpScopeTest.java | 86 ++++++++++
.../sparql/parser/SparqlParserNilTest.java | 50 ++++++
.../SparqlParserSelectDuplicateVarTest.java | 61 +++++++
.../parser/SparqlParserSurrogateTest.java | 57 +++++++
16 files changed, 678 insertions(+), 23 deletions(-)
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparisonCalendarTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserBnodeBgpScopeTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserNilTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSelectDuplicateVarTest.java
create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSurrogateTest.java
diff --git a/src/main/antlr/SparqlLexer.g4 b/src/main/antlr/SparqlLexer.g4
index fdb951589..e6fd83e59 100644
--- a/src/main/antlr/SparqlLexer.g4
+++ b/src/main/antlr/SparqlLexer.g4
@@ -171,7 +171,7 @@ PIPE : '|';
// MISC
-IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020'))* '>';
+IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020') | UCHAR)* '>';
PNAME_NS: PN_PREFIX? ':';
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
index 4a76fa6af..4328acfff 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedExpr.java
@@ -22,12 +22,20 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
/** Immutable KGRAM expression backed directly by a Corese-next AST term. */
public final class AstBackedExpr implements Expr {
private final TermAst source;
private final WhereCompiler whereCompiler;
+ /**
+ * Snapshot of the enclosing group's in-scope variables, captured at filter
+ * creation time so that {@link NextFilterFromAst#getVariables()} reports the
+ * correct scheduling dependencies even after {@code WhereCompiler} has moved
+ * to a different scope (e.g., after returning from a MINUS body).
+ */
+ private final Set capturedInScope;
private final NextFilterFromAst filterView;
private int index = ExprType.UNBOUND;
private int subtype = ExprType.GLOBAL;
@@ -41,6 +49,9 @@ public AstBackedExpr(TermAst source) {
AstBackedExpr(TermAst source, WhereCompiler whereCompiler) {
this.source = Objects.requireNonNull(source, "source");
this.whereCompiler = whereCompiler;
+ this.capturedInScope = (whereCompiler != null)
+ ? Set.copyOf(whereCompiler.inScopeVariables())
+ : Set.of();
this.operator = operator(source);
this.filterView = new NextFilterFromAst(this);
}
@@ -53,6 +64,11 @@ WhereCompiler whereCompiler() {
return whereCompiler;
}
+ /** Returns the in-scope variables as they were when this expression was compiled. */
+ Set capturedInScope() {
+ return capturedInScope;
+ }
+
@Override
public Filter getFilter() {
return filterView;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
index cb7f06cbb..19098eebf 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/ConstructQueryCompiler.java
@@ -76,15 +76,18 @@ private static Exp compileConstructTemplate(
/**
* Resolves a template term to a runtime {@link Node}. A variable reuses the body node when it is
* bound by the {@code WHERE}; otherwise it stays a fresh node (an unbound template variable is
- * valid SPARQL and simply skips its triple at instantiation, so this does not throw). IRIs, blank
- * nodes and literals become fresh constant nodes.
+ * valid SPARQL and simply skips its triple at instantiation, so this does not throw). Blank node
+ * labels become existential (blank-variable) nodes that generate a fresh blank node per solution,
+ * as required by the SPARQL specification. IRIs and literals become fresh constant nodes.
*/
private static Node constructNode(Query query, TermAst term, WhereCompiler compiler) {
if (term instanceof VarAst(String name)) {
Node bound = visibleBodyNode(query, name);
return bound != null ? bound : NodeImpl.forVariable(name);
}
- return compiler.termResolver().toNode(term);
+ // toPatternNode treats blank-node labels as existential variables (fresh per solution),
+ // while toNode would create constant blank nodes shared across all solutions.
+ return compiler.termResolver().toPatternNode(term);
}
private static Node visibleBodyNode(Query query, String name) {
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
index c381b0e31..d902e7a1e 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparison.java
@@ -46,6 +46,11 @@ static boolean valueEquals(DatatypeValue left, DatatypeValue right) {
if (isComparableCalendar(first, second)) {
return compareLiterals(first, second) == 0;
}
+ // Two calendar-type literals with different XSD types (e.g., xsd:date vs xsd:dateTime)
+ // are observably different RDF terms — different datatype IRIs imply they cannot be equal.
+ if (isCalendarType(first) && isCalendarType(second)) {
+ return false;
+ }
throw new QueryTypeErrorException("RDF literal values are not equality-comparable");
}
@@ -118,6 +123,13 @@ private static boolean isComparableCalendar(Literal left, Literal right) {
};
}
+ private static boolean isCalendarType(Literal literal) {
+ return literal.getCoreDatatype() instanceof XSDDatatype datatype && switch (datatype) {
+ case DATE, DATETIME, TIME -> true;
+ default -> false;
+ };
+ }
+
private static QueryTypeErrorException incomparable() {
return new QueryTypeErrorException(
"RDF values are not order-comparable in a SPARQL expression");
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
index e04c3da3d..031e15528 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextFilterFromAst.java
@@ -24,13 +24,13 @@ public final class NextFilterFromAst implements Filter {
@Override
public List getVariables() {
// Scheduling dependencies include variables inside EXISTS patterns (including
- // GRAPH names and inner FILTERs) only if they are bound in the enclosing query scope.
+ // GRAPH names and inner FILTERs) only if they are bound in the enclosing group scope.
+ // The scope is captured at filter creation time so that nested scopes (e.g. inside
+ // a MINUS body) are used rather than the outer WHERE scope.
// Purely local/existential variables of EXISTS must not be declared, so they do not
// cause postponement of the filter in OPTIONAL patterns (KGRAM Exp.optional/simpleBind).
Set names = new LinkedHashSet<>();
- Set inScope = (owner.whereCompiler() != null)
- ? owner.whereCompiler().inScopeVariables()
- : Set.of();
+ Set inScope = owner.capturedInScope();
boolean recExist = owner.isRecExist();
owner.sourceAst().orElseThrow().accept(new AbstractAstVisitor() {
@Override
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
index 60155b40e..130639c12 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
@@ -14,6 +14,8 @@
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst;
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@@ -206,8 +208,14 @@ private static int appendEscaped(StringBuilder result, String value, int escapeI
if (escape == 'u' || escape == 'U') {
int length = escape == 'u' ? 4 : 8;
if (escapeIndex + 1 + length <= value.length()) {
- result.appendCodePoint(Integer.parseInt(
- value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16));
+ int codePoint = Integer.parseInt(
+ value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16);
+ if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+ throw new QuerySyntaxException(
+ "Surrogate code point U+" + Integer.toHexString(codePoint).toUpperCase()
+ + " is not a valid Unicode scalar value in a SPARQL escape sequence");
+ }
+ result.appendCodePoint(codePoint);
return escapeIndex + 1 + length;
}
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
index 271bfd182..548f323e9 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java
@@ -32,6 +32,8 @@
import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
import fr.inria.corese.core.next.query.impl.engine.solution.Mappings;
+import fr.inria.corese.core.next.query.impl.sparql.parser.semantic.support.VariableScopeAnalyzer;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
@@ -141,7 +143,16 @@ case OptionalAst(PatternAst ast) -> {
}
case MinusAst(GroupGraphPatternAst pattern) -> {
Exp left = body;
+ // Use MINUS body scope so EXISTS filters inside see local variables
+ // (e.g. ?x from "?s1 :member ?x" inside the MINUS body) and are
+ // scheduled after those variables are bound, not before.
+ Set outerScope = inScopeVariables;
+ if (!outerScope.isEmpty()) {
+ inScopeVariables = Set.copyOf(
+ new VariableScopeAnalyzer().collectVisibleVariables(pattern));
+ }
Exp right = compile(pattern);
+ inScopeVariables = outerScope;
Exp minusExp = Exp.create(Type.MINUS, left, right);
body = Exp.create(Type.AND);
body.add(minusExp);
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
index b30b1c76a..4d7871cf8 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
@@ -38,12 +38,15 @@
import fr.inria.corese.core.next.storage.api.StorageManager;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
/**
* Internal orchestrator for the Corese-next SPARQL query path.
@@ -296,6 +299,9 @@ private Mappings evaluateWithTimeout(Eval eval, Query kgramQuery, Mapping initia
* Triples where any component is unbound or cannot be converted to a valid
* RDF term are silently skipped, matching standard SPARQL CONSTRUCT semantics.
*/
+ /** Counter for generating unique blank node labels in CONSTRUCT output. */
+ private static final AtomicLong CONSTRUCT_BNODE_COUNTER = new AtomicLong();
+
private List buildConstructStatements(Query kgramQuery, Mappings mappings) {
List templateEdges = new ArrayList<>();
Exp constructTemplate = kgramQuery.getConstruct();
@@ -308,10 +314,12 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
List statements = new ArrayList<>();
for (Mapping mapping : mappings) {
+ // Fresh blank node map per solution: blank-variable label → constant blank node
+ Map blankNodeMap = new HashMap<>();
for (Edge templateEdge : templateEdges) {
- Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping);
- Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping);
- Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping);
+ Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping, blankNodeMap);
+ Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping, blankNodeMap);
+ Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping, blankNodeMap);
if (subjectNode == null || predicateNode == null || objectNode == null) {
continue;
@@ -330,16 +338,27 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
}
/**
- * Resolves a CONSTRUCT template node: if the node is a variable, look it up in
- * the current mapping; if it is already a constant, return it directly.
+ * Resolves a CONSTRUCT template node:
+ *
+ * - Blank-variable nodes (existential blank nodes like {@code _:b0}) are
+ * resolved to a per-solution constant blank node, so that every occurrence
+ * of the same label within one solution maps to the same blank node.
+ * - Regular variable nodes are looked up in the current mapping.
+ * - Constant nodes are returned as-is.
+ *
*
- * @return the bound or constant node, or {@code null} when a variable is unbound
+ * @return the resolved node, or {@code null} when a regular variable is unbound
*/
- private Node resolveTemplateNode(Node templateNode, Mapping mapping) {
+ private Node resolveTemplateNode(Node templateNode, Mapping mapping, Map blankNodeMap) {
if (templateNode == null) {
return null;
}
if (templateNode.isVariable()) {
+ if (templateNode.isBlank()) {
+ // Existential blank node: fresh blank per solution, same label → same node
+ return blankNodeMap.computeIfAbsent(templateNode.getLabel(),
+ label -> NodeImpl.forBlank("cb" + CONSTRUCT_BNODE_COUNTER.getAndIncrement()));
+ }
return mapping.getNode(templateNode);
}
return templateNode;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
index ebe72c83c..d19f2c645 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
@@ -4,6 +4,7 @@
import fr.inria.corese.core.next.data.api.vocabulary.XSD;
import fr.inria.corese.core.next.generated.antlr.SparqlParser;
import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException;
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
import fr.inria.corese.core.next.query.impl.sparql.ast.IriAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.LiteralAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
@@ -91,9 +92,46 @@ public IriAst iri(String raw) {
*/
public LiteralAst literal(String lexical, String lang, String datatype) {
if (lexical == null) throw new IllegalArgumentException("Literal lexical is null");
+ validateNoSurrogateUchars(lexical);
return new LiteralAst(lexical, lang, datatype);
}
+ /**
+ * Scans a raw literal lexical form (including surrounding quotes) for
+ * escape sequences that encode Unicode surrogate code points (U+D800–U+DFFF),
+ * which are illegal in SPARQL and XML.
+ *
+ * @throws QuerySyntaxException if a surrogate code point is found
+ */
+ private static void validateNoSurrogateUchars(String lexical) {
+ int i = 0;
+ while (i < lexical.length()) {
+ char c = lexical.charAt(i);
+ if (c == '\\' && i + 1 < lexical.length()) {
+ char next = lexical.charAt(i + 1);
+ int hexLen = 0;
+ if (next == 'u') hexLen = 4;
+ else if (next == 'U') hexLen = 8;
+ if (hexLen > 0 && i + 2 + hexLen <= lexical.length()) {
+ String hex = lexical.substring(i + 2, i + 2 + hexLen);
+ try {
+ int codePoint = Integer.parseInt(hex, 16);
+ if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+ throw new QuerySyntaxException(
+ "Surrogate code point U+" + hex.toUpperCase()
+ + " is not a valid Unicode scalar value in a SPARQL escape sequence");
+ }
+ } catch (NumberFormatException ignored) {
+ // Not a valid hex sequence; ANTLR grammar ensures this won't happen for UCHAR
+ }
+ i += 2 + hexLen;
+ continue;
+ }
+ }
+ i++;
+ }
+ }
+
// --- ANTLR-context converters ---
public TermAst termFromVar(SparqlParser.Var_Context ctx) {
@@ -235,8 +273,8 @@ public TermAst termFromGraphTerm(SparqlParser.GraphTermContext ctx) {
return blankNodeResolver.apply(ctx.blankNode());
}
if (ctx.NIL() != null) {
- return iri("()");
- } // NIL = () in SPARQL
+ return iri("<" + RDF.nil.getIRI().stringValue() + ">");
+ } // NIL = rdf:nil
return iri(ctx.getText());
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
index 1b3dddb88..1a5cc674d 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
@@ -68,11 +68,71 @@ public void exitTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx) {
if (!inConstructTriples && !inConstructWhere) {
return;
}
- if (ctx.varOrTerm() == null || ctx.propertyListNotEmpty() == null) {
- return;
+ if (ctx.varOrTerm() != null && ctx.propertyListNotEmpty() != null) {
+ TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
+ addConstructProperties(subject, ctx.propertyListNotEmpty(), inConstructWhere);
+ } else if (ctx.triplesNode() != null) {
+ TermAst subject = subjectFromTriplesNode(ctx.triplesNode(), inConstructWhere);
+ if (ctx.propertyList() != null && ctx.propertyList().propertyListNotEmpty() != null) {
+ addConstructProperties(subject, ctx.propertyList().propertyListNotEmpty(), inConstructWhere);
+ }
+ }
+ }
+
+ /**
+ * Expands a {@code triplesNode} into the CONSTRUCT template (and optionally the WHERE BGP),
+ * returning the head term (an anonymous blank node for blank node property lists).
+ */
+ private TermAst subjectFromTriplesNode(SparqlParser.TriplesNodeContext ctx, boolean inConstructWhere) {
+ if (ctx.blankNodePropertyList() != null) {
+ TermAst blankNode = queryBuilder().newAnonymousBlankNode();
+ var inner = ctx.blankNodePropertyList().propertyListNotEmpty();
+ if (inner != null) {
+ addConstructProperties(blankNode, inner, inConstructWhere);
+ }
+ return blankNode;
+ }
+ if (ctx.collection() != null) {
+ return subjectFromCollection(ctx.collection(), inConstructWhere);
}
- TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
- var propertyList = ctx.propertyListNotEmpty();
+ return queryBuilder().iri(ctx.getText());
+ }
+
+ /**
+ * Expands an RDF collection {@code (e1 e2 ...)} into rdf:first/rdf:rest chains
+ * in the CONSTRUCT template and returns the head blank node.
+ */
+ private TermAst subjectFromCollection(SparqlParser.CollectionContext ctx, boolean inConstructWhere) {
+ var nodes = ctx.graphNode();
+ if (nodes.isEmpty()) {
+ return queryBuilder().iri("");
+ }
+ TermAst first = queryBuilder().iri("");
+ TermAst rest = queryBuilder().iri("");
+ TermAst nil = queryBuilder().iri("");
+ TermAst head = queryBuilder().newAnonymousBlankNode();
+ TermAst current = head;
+ for (int i = 0; i < nodes.size(); i++) {
+ TermAst element = queryBuilder().termFromGraphNode(nodes.get(i));
+ queryBuilder().addConstructTriple(current, first, element);
+ if (inConstructWhere) queryBuilder().addTriple(current, first, element);
+ if (i == nodes.size() - 1) {
+ queryBuilder().addConstructTriple(current, rest, nil);
+ if (inConstructWhere) queryBuilder().addTriple(current, rest, nil);
+ } else {
+ TermAst next = queryBuilder().newAnonymousBlankNode();
+ queryBuilder().addConstructTriple(current, rest, next);
+ if (inConstructWhere) queryBuilder().addTriple(current, rest, next);
+ current = next;
+ }
+ }
+ return head;
+ }
+
+ private void addConstructProperties(
+ TermAst subject,
+ SparqlParser.PropertyListNotEmptyContext propertyList,
+ boolean inConstructWhere) {
for (int verbIndex = 0; verbIndex < propertyList.verb().size(); verbIndex++) {
TermAst predicate = queryBuilder().termFromVerb(propertyList.verb(verbIndex));
List objects = queryBuilder().termListFromObjectList(propertyList.objectList(verbIndex));
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparisonCalendarTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparisonCalendarTest.java
new file mode 100644
index 000000000..156b5ccb0
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeValueComparisonCalendarTest.java
@@ -0,0 +1,73 @@
+package fr.inria.corese.core.next.query.impl.sparql.bridge;
+
+import fr.inria.corese.core.next.query.Repositories;
+import fr.inria.corese.core.next.storage.impl.memory.MemoryStorageManager;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for cross-type calendar equality in {@code NativeValueComparison}.
+ *
+ * Two calendar literals with different XSD types (e.g., {@code xsd:date}
+ * vs {@code xsd:dateTime}) are distinct RDF terms and must compare as {@code false},
+ * not throw a type error.
+ */
+@DisplayName("NativeValueComparison - cross-type calendar equality")
+class NativeValueComparisonCalendarTest {
+
+ private static final String PREFIX =
+ "PREFIX xsd: ";
+
+ private boolean ask(String filter) {
+ try (var repo = Repositories.create(MemoryStorageManager.builder().build());
+ var conn = repo.getConnection()) {
+ return conn.prepareBooleanQuery(PREFIX + "ASK { FILTER(" + filter + ") }").evaluate();
+ }
+ }
+
+ @Test
+ @DisplayName("xsd:date = xsd:dateTime returns false")
+ void dateDifferentFromDateTime() {
+ assertFalse(ask(
+ "\"2024-01-01\"^^xsd:date = \"2024-01-01T00:00:00\"^^xsd:dateTime"),
+ "xsd:date and xsd:dateTime have different XSD types and must not be equal");
+ }
+
+ @Test
+ @DisplayName("xsd:date = xsd:time returns false")
+ void dateDifferentFromTime() {
+ assertFalse(ask(
+ "\"2024-01-01\"^^xsd:date = \"10:00:00\"^^xsd:time"));
+ }
+
+ @Test
+ @DisplayName("xsd:dateTime = xsd:time returns false")
+ void dateTimeDifferentFromTime() {
+ assertFalse(ask(
+ "\"2024-01-01T00:00:00\"^^xsd:dateTime = \"00:00:00\"^^xsd:time"));
+ }
+
+ @Test
+ @DisplayName("identical xsd:date values are equal")
+ void sameDateEquality() {
+ assertTrue(ask(
+ "\"2024-01-01\"^^xsd:date = \"2024-01-01\"^^xsd:date"));
+ }
+
+ @Test
+ @DisplayName("different xsd:date values are not equal")
+ void differentDatesNotEqual() {
+ assertFalse(ask(
+ "\"2024-01-01\"^^xsd:date = \"2024-01-02\"^^xsd:date"));
+ }
+
+ @Test
+ @DisplayName("identical xsd:dateTime values are equal")
+ void sameDateTimeEquality() {
+ assertTrue(ask(
+ "\"2024-01-01T12:00:00\"^^xsd:dateTime = \"2024-01-01T12:00:00\"^^xsd:dateTime"));
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
new file mode 100644
index 000000000..0407d7d49
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
@@ -0,0 +1,161 @@
+package fr.inria.corese.core.next.query.impl.sparql.execution;
+
+import fr.inria.corese.core.next.data.api.model.Statement;
+import fr.inria.corese.core.next.data.api.term.BNode;
+import fr.inria.corese.core.next.query.api.result.GraphQueryResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for CONSTRUCT blank-node handling.
+ *
+ * SPARQL 1.1 §10.1.3: A blank-node label in a CONSTRUCT template is an
+ * existential variable — each solution produces a fresh blank node,
+ * but occurrences of the same label within one solution all map to
+ * the same blank node.
+ */
+@DisplayName("CONSTRUCT - blank-node per-solution semantics")
+class ConstructBlankNodesTest extends PipelineTestSupport {
+
+ @BeforeEach
+ @Override
+ void setUp() {
+ super.setUp();
+ // Add a second triple so we get two solutions
+ insert(iri(ALICE), iri(KNOWS), iri("http://example.org/carol"));
+ }
+
+ private List collect(String sparql) {
+ List results = new ArrayList<>();
+ try (GraphQueryResult r = executor.evaluateGraph(sparql)) {
+ while (r.hasNext()) results.add(r.next());
+ }
+ return results;
+ }
+
+ @Test
+ @DisplayName("different solutions produce different blank nodes for the same label")
+ void differentSolutionsProduceDifferentBlankNodes() {
+ // Two solutions (alice→bob, alice→carol): each solution gets its own _:b
+ List stmts = collect("""
+ CONSTRUCT { _:b ?o }
+ WHERE { ?s ?o }
+ """);
+
+ assertEquals(2, stmts.size(), "Two solutions must produce two statements");
+
+ BNode b1 = assertInstanceOf(BNode.class, stmts.get(0).getSubject());
+ BNode b2 = assertInstanceOf(BNode.class, stmts.get(1).getSubject());
+ assertNotEquals(b1.getLabel(), b2.getLabel(),
+ "Blank nodes from different solutions must be distinct");
+ }
+
+ @Test
+ @DisplayName("same blank-node label used twice in one template solution maps to one node")
+ void sameLabelWithinOneSolutionMapsToSameBlankNode() {
+ // One solution; _:b appears as both subject and object of different triples
+ List stmts = collect("""
+ CONSTRUCT {
+ _:b ?o .
+ _:b
+ }
+ WHERE { ?o }
+ LIMIT 1
+ """);
+
+ assertEquals(2, stmts.size(), "Template with two triples must produce two statements");
+
+ // First triple: _:b is subject
+ BNode bAsSubject = assertInstanceOf(BNode.class, stmts.get(0).getSubject());
+ // Second triple: _:b is object
+ BNode bAsObject = assertInstanceOf(BNode.class, stmts.get(1).getObject());
+
+ assertEquals(bAsSubject.getLabel(), bAsObject.getLabel(),
+ "The same blank-node label within one solution must resolve to the same blank node");
+ }
+
+ @Test
+ @DisplayName("RDF collection in CONSTRUCT template produces fresh rdf:first/rdf:rest chains per solution")
+ void collectionTemplateFreshChainsPerSolution() {
+ // setUp inserts (alice, knows, bob); @BeforeEach adds (alice, knows, carol) → 2 solutions
+ final String RDF_FIRST = "http://www.w3.org/1999/02/22-rdf-syntax-ns#first";
+ final String RDF_REST = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest";
+ final String RDF_NIL = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil";
+ final String PROP = "http://example.org/prop";
+
+ List stmts = collect("""
+ PREFIX ex:
+ CONSTRUCT { (?s ?o) ex:prop ?p }
+ WHERE { ?s ?p ?o }
+ """);
+
+ // 2 solutions × 5 triples (4 list-structure + 1 ex:prop) = 10
+ assertEquals(10, stmts.size(), "2 solutions × 5 triples each = 10 total");
+
+ // Extract the two list-head blank-node labels (subjects of ex:prop triples)
+ List headLabels = stmts.stream()
+ .filter(s -> PROP.equals(s.getPredicate().stringValue()))
+ .map(s -> assertInstanceOf(BNode.class, s.getSubject(),
+ "list-head subject must be a blank node").getLabel())
+ .toList();
+
+ assertEquals(2, headLabels.size());
+ assertNotEquals(headLabels.get(0), headLabels.get(1),
+ "Two solutions must produce distinct list-head blank nodes");
+
+ // Each head must carry exactly one rdf:first and one rdf:rest
+ for (String headLabel : headLabels) {
+ long firstCount = stmts.stream()
+ .filter(s -> s.getSubject() instanceof BNode b && b.getLabel().equals(headLabel)
+ && RDF_FIRST.equals(s.getPredicate().stringValue()))
+ .count();
+ long restCount = stmts.stream()
+ .filter(s -> s.getSubject() instanceof BNode b && b.getLabel().equals(headLabel)
+ && RDF_REST.equals(s.getPredicate().stringValue()))
+ .count();
+ assertEquals(1, firstCount, "Each list-head must have exactly one rdf:first");
+ assertEquals(1, restCount, "Each list-head must have exactly one rdf:rest");
+ }
+
+ // Tails (rdf:rest targets from heads) must also be distinct blank nodes
+ List tailLabels = stmts.stream()
+ .filter(s -> s.getSubject() instanceof BNode b && headLabels.contains(b.getLabel())
+ && RDF_REST.equals(s.getPredicate().stringValue()))
+ .map(s -> assertInstanceOf(BNode.class, s.getObject(),
+ "rdf:rest must point to a blank node tail").getLabel())
+ .toList();
+
+ assertEquals(2, tailLabels.size());
+ assertNotEquals(tailLabels.get(0), tailLabels.get(1),
+ "Two solutions must produce distinct list-tail blank nodes");
+
+ // Each tail must terminate the chain with rdf:rest = rdf:nil
+ for (String tailLabel : tailLabels) {
+ boolean terminatesWithNil = stmts.stream()
+ .anyMatch(s -> s.getSubject() instanceof BNode b && b.getLabel().equals(tailLabel)
+ && RDF_REST.equals(s.getPredicate().stringValue())
+ && RDF_NIL.equals(s.getObject().stringValue()));
+ assertTrue(terminatesWithNil, "List tail must terminate with rdf:rest rdf:nil");
+ }
+ }
+
+ @Test
+ @DisplayName("CONSTRUCT with only variables produces concrete IRI statements")
+ void constructWithVariablesProducesConcreteStatements() {
+ List stmts = collect("""
+ CONSTRUCT { ?s ?o }
+ WHERE { ?s ?o }
+ LIMIT 1
+ """);
+
+ assertEquals(1, stmts.size());
+ assertNotNull(stmts.getFirst().getSubject());
+ assertNotNull(stmts.getFirst().getObject());
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserBnodeBgpScopeTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserBnodeBgpScopeTest.java
new file mode 100644
index 000000000..0054614c5
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserBnodeBgpScopeTest.java
@@ -0,0 +1,86 @@
+package fr.inria.corese.core.next.query.impl.sparql.parser;
+
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests that blank-node labels are validated against the SPARQL 1.1 rule:
+ * a blank-node label may not appear in more than one Basic Graph Pattern (BGP).
+ *
+ * A new BGP starts after each BGP-breaking construct: OPTIONAL, UNION branch,
+ * GRAPH, SERVICE, MINUS, EXISTS, or any nested plain group.
+ */
+@DisplayName("SPARQL parser - blank-node BGP scope validation")
+class SparqlParserBnodeBgpScopeTest extends AbstractSparqlParserFeatureTest {
+
+ @Test
+ @DisplayName("same blank-node label used twice within one BGP is valid")
+ void sameLabelInOneBgpIsValid() {
+ assertDoesNotThrow(() -> newParserDefault().parse("""
+ SELECT * WHERE {
+ _:b ?o .
+ _:b ?x
+ }
+ """));
+ }
+
+ @Test
+ @DisplayName("two different blank-node labels in one BGP are valid")
+ void differentLabelsInOneBgpAreValid() {
+ assertDoesNotThrow(() -> newParserDefault().parse("""
+ SELECT * WHERE {
+ _:b1 _:b2 .
+ _:b1 _:b2
+ }
+ """));
+ }
+
+ @Test
+ @DisplayName("same blank-node label in main BGP and OPTIONAL body is rejected")
+ void sameLabelInOptionalIsRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT * WHERE {
+ _:b ?o .
+ OPTIONAL { _:b ?x }
+ }
+ """), "A blank-node label must not cross an OPTIONAL boundary");
+ }
+
+ @Test
+ @DisplayName("same blank-node label in two UNION branches is rejected")
+ void sameLabelInUnionBranchesIsRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT * WHERE {
+ { _:b ?o }
+ UNION
+ { _:b ?x }
+ }
+ """), "A blank-node label must not appear in both UNION branches");
+ }
+
+ @Test
+ @DisplayName("same blank-node label across a GRAPH boundary is rejected")
+ void sameLabelAcrossGraphBoundaryIsRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT * WHERE {
+ GRAPH { _:b ?o }
+ _:b ?x
+ }
+ """), "A blank-node label must not cross a GRAPH clause boundary");
+ }
+
+ @Test
+ @DisplayName("same blank-node label in WHERE BGP and MINUS body is rejected")
+ void sameLabelInMinusBodyIsRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT * WHERE {
+ _:b ?o .
+ MINUS { _:b ?x }
+ }
+ """), "A blank-node label must not cross a MINUS boundary");
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserNilTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserNilTest.java
new file mode 100644
index 000000000..eecdda69c
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserNilTest.java
@@ -0,0 +1,50 @@
+package fr.inria.corese.core.next.query.impl.sparql.parser;
+
+import fr.inria.corese.core.next.query.impl.sparql.ast.IriAst;
+import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * Tests that the SPARQL NIL token {@code ()} is mapped to {@code rdf:nil}
+ * and not left as the raw text {@code ()}.
+ */
+@DisplayName("SPARQL parser - NIL () maps to rdf:nil")
+class SparqlParserNilTest extends AbstractSparqlParserFeatureTest {
+
+ private static final String RDF_NIL = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil";
+
+ @Test
+ @DisplayName("NIL () in object position resolves to rdf:nil IRI")
+ void nilInObjectPositionResolvesToRdfNil() {
+ var ast = newParserDefault().parse("""
+ SELECT * WHERE {
+ ?s ?p ()
+ }
+ """);
+
+ TriplePatternAst triple = firstWhereTriple(ast);
+ IriAst object = assertInstanceOf(IriAst.class, triple.object(),
+ "NIL () must produce an IriAst, not a raw text term");
+ assertEquals("<" + RDF_NIL + ">", object.raw(),
+ "NIL () must be mapped to the rdf:nil IRI, not to '()'");
+ }
+
+ @Test
+ @DisplayName("NIL () in subject position resolves to rdf:nil IRI")
+ void nilInSubjectPositionResolvesToRdfNil() {
+ var ast = newParserDefault().parse("""
+ SELECT * WHERE {
+ () ?p ?o
+ }
+ """);
+
+ TriplePatternAst triple = firstWhereTriple(ast);
+ IriAst subject = assertInstanceOf(IriAst.class, triple.subject(),
+ "NIL () in subject must produce an IriAst");
+ assertEquals("<" + RDF_NIL + ">", subject.raw());
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSelectDuplicateVarTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSelectDuplicateVarTest.java
new file mode 100644
index 000000000..8483009e0
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSelectDuplicateVarTest.java
@@ -0,0 +1,61 @@
+package fr.inria.corese.core.next.query.impl.sparql.parser;
+
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the SELECT clause projection rules from SPARQL 1.1 §18.2.4.4:
+ *
+ * - Plain variable projection is a set — duplicates are silently deduplicated.
+ * - A plain variable reference after an alias is permitted (it just references the alias).
+ * - An alias whose target variable was already projected (bare or via another alias) is rejected.
+ *
+ */
+@DisplayName("SPARQL parser - SELECT clause projection rules")
+class SparqlParserSelectDuplicateVarTest extends AbstractSparqlParserFeatureTest {
+
+ @Test
+ @DisplayName("SELECT with distinct variables is valid")
+ void distinctVariablesAreValid() {
+ assertDoesNotThrow(() -> newParserDefault().parse("""
+ SELECT ?x ?y WHERE { ?x ?p ?y }
+ """));
+ }
+
+ @Test
+ @DisplayName("SELECT ?x ?x is valid — duplicates are silently deduplicated (SPARQL 1.1 §18.2.4.4)")
+ void duplicateBareVariablesAreSilentlyDeduplicated() {
+ assertDoesNotThrow(() -> newParserDefault().parse("""
+ SELECT ?x ?x WHERE { ?x ?p ?o }
+ """), "Duplicate plain variables must be silently deduplicated per SPARQL 1.1");
+ }
+
+ @Test
+ @DisplayName("SELECT (expr AS ?x) ?x is valid — bare reference after alias is permitted")
+ void bareReferenceAfterAliasIsValid() {
+ // ?x is not in the WHERE scope, so the alias introduces it and the bare ref is just a back-reference
+ assertDoesNotThrow(() -> newParserDefault().parse("""
+ SELECT (?o AS ?x) ?x WHERE { ?s ?p ?o }
+ """), "A bare variable reference after an alias must be permitted per SPARQL 1.1");
+ }
+
+ @Test
+ @DisplayName("SELECT ?x (1 AS ?x) is rejected — alias redefines an already-projected variable")
+ void aliasRedefineBareVariableIsRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT ?x (1 AS ?x) WHERE { ?x ?p ?o }
+ """), "An alias may not redefine a variable already in the projection");
+ }
+
+ @Test
+ @DisplayName("SELECT (?o AS ?label) (?x AS ?label) is rejected — two aliases with same target")
+ void twoAliasesWithSameNameAreRejected() {
+ assertThrows(QuerySyntaxException.class, () -> newParserDefault().parse("""
+ SELECT (?o AS ?label) (?x AS ?label) WHERE { ?x ?p ?o }
+ """), "Two expression aliases may not share the same variable name in SELECT");
+ }
+}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSurrogateTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSurrogateTest.java
new file mode 100644
index 000000000..7a331a6fb
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlParserSurrogateTest.java
@@ -0,0 +1,57 @@
+package fr.inria.corese.core.next.query.impl.sparql.parser;
+
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests that surrogate code points (U+D800–U+DFFF) are rejected when they appear
+ * as Unicode escape sequences in SPARQL
+ * string literals.
+ *
+ */
+@DisplayName("SPARQL parser - surrogate code point rejection in literals")
+class SparqlParserSurrogateTest extends AbstractSparqlParserFeatureTest {
+
+ private static final String BASE = "SELECT * WHERE { ?s ?p ";
+ private static final String END = " }";
+
+ @ParameterizedTest(name = "literal \\u{0} is rejected")
+ @ValueSource(strings = {"D800", "DBFF", "DC00", "DFFF"})
+ @DisplayName("literal with \\uXXXX surrogate is rejected")
+ void literalWithSurrogateUcharIsRejected(String hex) {
+ String query = BASE + "\"\\u" + hex + "\"" + END;
+ assertThrows(QuerySyntaxException.class,
+ () -> newParserDefault().parse(query),
+ "Surrogate U+" + hex + " in a literal must be rejected");
+ }
+
+ @ParameterizedTest(name = "literal \\U0000{0} is rejected")
+ @ValueSource(strings = {"D800", "DBFF", "DC00", "DFFF"})
+ @DisplayName("literal with \\UXXXXXXXX surrogate is rejected")
+ void literalWithLongSurrogateUcharIsRejected(String hex) {
+ String query = BASE + "\"\\U0000" + hex + "\"" + END;
+ assertThrows(QuerySyntaxException.class,
+ () -> newParserDefault().parse(query),
+ "Surrogate U+" + hex + " (long form) in a literal must be rejected");
+ }
+
+ @Test
+ @DisplayName("literal with valid \\u0041 ('A') is accepted")
+ void literalWithValidShortEscapeIsAccepted() {
+ assertDoesNotThrow(() -> newParserDefault().parse(
+ BASE + "\"\\u0041\"" + END));
+ }
+
+ @Test
+ @DisplayName("literal with valid \\U0001F600 (emoji) is accepted")
+ void literalWithValidLongEscapeIsAccepted() {
+ assertDoesNotThrow(() -> newParserDefault().parse(
+ BASE + "\"\\U0001F600\"" + END));
+ }
+}
From 10d09479e90d4a5f60c12d0486fd7f2a9d0eba6c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?=
Date: Tue, 29 Sep 2026 11:16:42 +0200
Subject: [PATCH 3/3] fix(sparql): decode IRI escapes, expand construct
collections and ensure blank node uniqueness
---
.../sparql/bridge/SparqlTermResolver.java | 45 ++------------
.../execution/NextSparqlPipelineExecutor.java | 33 +++++++---
.../impl/sparql/parser/SparqlIriEscapes.java | 49 +++++++++++++++
.../impl/sparql/parser/SparqlTermBuilder.java | 4 ++
.../listener/ConstructQueryAstListener.java | 23 ++++---
.../parser/listener/PrologueAstListener.java | 5 +-
.../sparql/bridge/SparqlTermResolverTest.java | 22 +++++++
.../execution/ConstructBlankNodesTest.java | 62 +++++++++++++++++++
8 files changed, 182 insertions(+), 61 deletions(-)
create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlIriEscapes.java
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
index 130639c12..6b5fc6099 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java
@@ -14,7 +14,7 @@
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst;
-import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+import fr.inria.corese.core.next.query.impl.sparql.parser.SparqlIriEscapes;
import java.util.HashMap;
import java.util.Map;
@@ -42,7 +42,7 @@ public String resolveIri(String raw) {
return null;
}
if (raw.startsWith("<") && raw.endsWith(">")) {
- return resolveRelativeIri(RdfText.stripAngleBrackets(raw));
+ return resolveRelativeIri(SparqlIriEscapes.decode(RdfText.stripAngleBrackets(raw)));
}
if (raw.startsWith(IOConstants.BLANK_NODE_PREFIX)) {
return raw;
@@ -87,7 +87,7 @@ public String normalizeDatatypeIri(String datatype) {
return null;
}
if (datatype.startsWith("<") && datatype.endsWith(">")) {
- return resolveRelativeIri(RdfText.stripAngleBrackets(datatype));
+ return resolveRelativeIri(SparqlIriEscapes.decode(RdfText.stripAngleBrackets(datatype)));
}
if (datatype.contains("://")
|| (IRIUtils.isAbsoluteIRI(datatype) && !prefixes.hasPrefix(prefix(datatype)))) {
@@ -164,7 +164,7 @@ private String resolvePrefixedIri(String raw) {
return raw;
}
String namespace = prefixes.getNamespace(raw.substring(0, colon));
- return namespace == null ? raw : namespace + unescapePName(raw.substring(colon + 1));
+ return namespace == null ? raw : namespace + SparqlIriEscapes.decode(raw.substring(colon + 1));
}
String resolveRelativeIri(String iri) {
@@ -185,41 +185,4 @@ private static String prefix(String iri) {
return colon < 0 ? iri : iri.substring(0, colon);
}
- private static String unescapePName(String local) {
- if (local == null || !local.contains("\\")) {
- return local;
- }
- StringBuilder result = new StringBuilder(local.length());
- int index = 0;
- while (index < local.length()) {
- char character = local.charAt(index);
- if (character == '\\' && index + 1 < local.length()) {
- index = appendEscaped(result, local, index + 1);
- } else {
- result.append(character);
- index++;
- }
- }
- return result.toString();
- }
-
- private static int appendEscaped(StringBuilder result, String value, int escapeIndex) {
- char escape = value.charAt(escapeIndex);
- if (escape == 'u' || escape == 'U') {
- int length = escape == 'u' ? 4 : 8;
- if (escapeIndex + 1 + length <= value.length()) {
- int codePoint = Integer.parseInt(
- value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16);
- if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
- throw new QuerySyntaxException(
- "Surrogate code point U+" + Integer.toHexString(codePoint).toUpperCase()
- + " is not a valid Unicode scalar value in a SPARQL escape sequence");
- }
- result.appendCodePoint(codePoint);
- return escapeIndex + 1 + length;
- }
- }
- result.append(escape);
- return escapeIndex + 1;
- }
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
index 4d7871cf8..3a68b959c 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java
@@ -39,14 +39,15 @@
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicLong;
/**
* Internal orchestrator for the Corese-next SPARQL query path.
@@ -299,9 +300,6 @@ private Mappings evaluateWithTimeout(Eval eval, Query kgramQuery, Mapping initia
* Triples where any component is unbound or cannot be converted to a valid
* RDF term are silently skipped, matching standard SPARQL CONSTRUCT semantics.
*/
- /** Counter for generating unique blank node labels in CONSTRUCT output. */
- private static final AtomicLong CONSTRUCT_BNODE_COUNTER = new AtomicLong();
-
private List buildConstructStatements(Query kgramQuery, Mappings mappings) {
List templateEdges = new ArrayList<>();
Exp constructTemplate = kgramQuery.getConstruct();
@@ -313,13 +311,22 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
ValueFactory factory = Values.factory();
List statements = new ArrayList<>();
+ // Reserve every solution’s labels before instantiation, including later solutions.
+ Set usedBlankLabels = new HashSet<>();
+ for (Mapping mapping : mappings) {
+ for (Node node : mapping.getNodes()) {
+ if (node != null && node.isBlank()) {
+ usedBlankLabels.add(node.getLabel());
+ }
+ }
+ }
for (Mapping mapping : mappings) {
// Fresh blank node map per solution: blank-variable label → constant blank node
Map blankNodeMap = new HashMap<>();
for (Edge templateEdge : templateEdges) {
- Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping, blankNodeMap);
- Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping, blankNodeMap);
- Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping, blankNodeMap);
+ Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping, blankNodeMap, usedBlankLabels);
+ Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping, blankNodeMap, usedBlankLabels);
+ Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping, blankNodeMap, usedBlankLabels);
if (subjectNode == null || predicateNode == null || objectNode == null) {
continue;
@@ -349,7 +356,9 @@ private List buildConstructStatements(Query kgramQuery, Mappings mapp
*
* @return the resolved node, or {@code null} when a regular variable is unbound
*/
- private Node resolveTemplateNode(Node templateNode, Mapping mapping, Map blankNodeMap) {
+ private Node resolveTemplateNode(
+ Node templateNode, Mapping mapping,
+ Map blankNodeMap, Set usedBlankLabels) {
if (templateNode == null) {
return null;
}
@@ -357,7 +366,13 @@ private Node resolveTemplateNode(Node templateNode, Mapping mapping, Map NodeImpl.forBlank("cb" + CONSTRUCT_BNODE_COUNTER.getAndIncrement()));
+ label -> {
+ Node blank;
+ do {
+ blank = NodeImpl.forValue(Values.factory().createBNode());
+ } while (!usedBlankLabels.add(blank.getLabel()));
+ return blank;
+ });
}
return mapping.getNode(templateNode);
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlIriEscapes.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlIriEscapes.java
new file mode 100644
index 000000000..74b3cf2f2
--- /dev/null
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlIriEscapes.java
@@ -0,0 +1,49 @@
+package fr.inria.corese.core.next.query.impl.sparql.parser;
+
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
+
+/** Decodes Unicode escapes in IRI references and character escapes in prefixed names. */
+public final class SparqlIriEscapes {
+
+ private SparqlIriEscapes() {
+ }
+
+ /** Decodes one layer of escapes from a grammar-validated IRI token. */
+ public static String decode(String value) {
+ if (value == null || !value.contains("\\")) {
+ return value;
+ }
+ StringBuilder result = new StringBuilder(value.length());
+ int index = 0;
+ while (index < value.length()) {
+ char character = value.charAt(index);
+ if (character == '\\' && index + 1 < value.length()) {
+ index = appendEscaped(result, value, index + 1);
+ } else {
+ result.append(character);
+ index++;
+ }
+ }
+ return result.toString();
+ }
+
+ private static int appendEscaped(StringBuilder result, String value, int escapeIndex) {
+ char escape = value.charAt(escapeIndex);
+ if (escape == 'u' || escape == 'U') {
+ int length = escape == 'u' ? 4 : 8;
+ if (escapeIndex + 1 + length <= value.length()) {
+ long codePoint = Long.parseLong(
+ value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16);
+ if (codePoint > Character.MAX_CODE_POINT || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
+ throw new QuerySyntaxException(
+ "Code point U+" + Long.toHexString(codePoint).toUpperCase()
+ + " is not a valid Unicode scalar value in a SPARQL escape sequence");
+ }
+ result.appendCodePoint((int) codePoint);
+ return escapeIndex + 1 + length;
+ }
+ }
+ result.append(escape);
+ return escapeIndex + 1;
+ }
+}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
index d19f2c645..7f848f387 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/SparqlTermBuilder.java
@@ -109,6 +109,10 @@ private static void validateNoSurrogateUchars(String lexical) {
char c = lexical.charAt(i);
if (c == '\\' && i + 1 < lexical.length()) {
char next = lexical.charAt(i + 1);
+ if (next == '\\') {
+ i += 2;
+ continue;
+ }
int hexLen = 0;
if (next == 'u') hexLen = 4;
else if (next == 'U') hexLen = 8;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
index 1a5cc674d..5931e0879 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/ConstructQueryAstListener.java
@@ -6,8 +6,6 @@
import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst;
import org.antlr.v4.runtime.RuleContext;
-import java.util.List;
-
/**
* SPARQL CONSTRUCT query feature: sets query type, collects the CONSTRUCT template
* (triples to instantiate from WHERE bindings) and delegates the WHERE clause to {@link BgpAstListener}.
@@ -72,7 +70,7 @@ public void exitTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx) {
TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
addConstructProperties(subject, ctx.propertyListNotEmpty(), inConstructWhere);
} else if (ctx.triplesNode() != null) {
- TermAst subject = subjectFromTriplesNode(ctx.triplesNode(), inConstructWhere);
+ TermAst subject = expandTriplesNode(ctx.triplesNode(), inConstructWhere);
if (ctx.propertyList() != null && ctx.propertyList().propertyListNotEmpty() != null) {
addConstructProperties(subject, ctx.propertyList().propertyListNotEmpty(), inConstructWhere);
}
@@ -83,7 +81,7 @@ public void exitTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx) {
* Expands a {@code triplesNode} into the CONSTRUCT template (and optionally the WHERE BGP),
* returning the head term (an anonymous blank node for blank node property lists).
*/
- private TermAst subjectFromTriplesNode(SparqlParser.TriplesNodeContext ctx, boolean inConstructWhere) {
+ private TermAst expandTriplesNode(SparqlParser.TriplesNodeContext ctx, boolean inConstructWhere) {
if (ctx.blankNodePropertyList() != null) {
TermAst blankNode = queryBuilder().newAnonymousBlankNode();
var inner = ctx.blankNodePropertyList().propertyListNotEmpty();
@@ -93,7 +91,7 @@ private TermAst subjectFromTriplesNode(SparqlParser.TriplesNodeContext ctx, bool
return blankNode;
}
if (ctx.collection() != null) {
- return subjectFromCollection(ctx.collection(), inConstructWhere);
+ return expandCollection(ctx.collection(), inConstructWhere);
}
return queryBuilder().iri(ctx.getText());
}
@@ -102,7 +100,7 @@ private TermAst subjectFromTriplesNode(SparqlParser.TriplesNodeContext ctx, bool
* Expands an RDF collection {@code (e1 e2 ...)} into rdf:first/rdf:rest chains
* in the CONSTRUCT template and returns the head blank node.
*/
- private TermAst subjectFromCollection(SparqlParser.CollectionContext ctx, boolean inConstructWhere) {
+ private TermAst expandCollection(SparqlParser.CollectionContext ctx, boolean inConstructWhere) {
var nodes = ctx.graphNode();
if (nodes.isEmpty()) {
return queryBuilder().iri("");
@@ -113,7 +111,7 @@ private TermAst subjectFromCollection(SparqlParser.CollectionContext ctx, boolea
TermAst head = queryBuilder().newAnonymousBlankNode();
TermAst current = head;
for (int i = 0; i < nodes.size(); i++) {
- TermAst element = queryBuilder().termFromGraphNode(nodes.get(i));
+ TermAst element = termFromConstructGraphNode(nodes.get(i), inConstructWhere);
queryBuilder().addConstructTriple(current, first, element);
if (inConstructWhere) queryBuilder().addTriple(current, first, element);
if (i == nodes.size() - 1) {
@@ -129,14 +127,21 @@ private TermAst subjectFromCollection(SparqlParser.CollectionContext ctx, boolea
return head;
}
+ private TermAst termFromConstructGraphNode(SparqlParser.GraphNodeContext ctx, boolean inConstructWhere) {
+ if (ctx.triplesNode() != null) {
+ return expandTriplesNode(ctx.triplesNode(), inConstructWhere);
+ }
+ return queryBuilder().termFromVarOrTerm(ctx.varOrTerm());
+ }
+
private void addConstructProperties(
TermAst subject,
SparqlParser.PropertyListNotEmptyContext propertyList,
boolean inConstructWhere) {
for (int verbIndex = 0; verbIndex < propertyList.verb().size(); verbIndex++) {
TermAst predicate = queryBuilder().termFromVerb(propertyList.verb(verbIndex));
- List objects = queryBuilder().termListFromObjectList(propertyList.objectList(verbIndex));
- for (TermAst object : objects) {
+ for (var objectContext : propertyList.objectList(verbIndex).object_()) {
+ TermAst object = termFromConstructGraphNode(objectContext.graphNode(), inConstructWhere);
queryBuilder().addConstructTriple(subject, predicate, object);
if (inConstructWhere) {
queryBuilder().addTriple(subject, predicate, object);
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/PrologueAstListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/PrologueAstListener.java
index 38acb2138..c7fed2b3a 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/PrologueAstListener.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/parser/listener/PrologueAstListener.java
@@ -3,6 +3,7 @@
import fr.inria.corese.core.next.generated.antlr.SparqlParser;
import fr.inria.corese.core.next.query.impl.sparql.parser.SparqlAstBuilder;
import fr.inria.corese.core.next.common.text.RdfText;
+import fr.inria.corese.core.next.query.impl.sparql.parser.SparqlIriEscapes;
public class PrologueAstListener extends AbstractSparqlAstListener {
public PrologueAstListener(SparqlAstBuilder builder) {
@@ -11,11 +12,11 @@ public PrologueAstListener(SparqlAstBuilder builder) {
@Override
public void exitBaseDecl(SparqlParser.BaseDeclContext ctx) {
- builder().setBaseUri(RdfText.stripAngleBrackets(ctx.IRI_REF().getText()));
+ builder().setBaseUri(RdfText.stripAngleBrackets(SparqlIriEscapes.decode(ctx.IRI_REF().getText())));
}
@Override
public void exitPrefixDecl(SparqlParser.PrefixDeclContext ctx) {
- builder().addPrefix(ctx.PNAME_NS().getText(), ctx.IRI_REF().getText());
+ builder().addPrefix(ctx.PNAME_NS().getText(), SparqlIriEscapes.decode(ctx.IRI_REF().getText()));
}
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java
index c91c2d5af..f60e677f0 100644
--- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java
@@ -2,6 +2,7 @@
import fr.inria.corese.core.next.data.api.vocabulary.RDF;
import fr.inria.corese.core.next.data.api.vocabulary.XSD;
+import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException;
import fr.inria.corese.core.next.query.impl.sparql.ast.IriAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.PrefixDeclarationAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.QueryPrologueAst;
@@ -10,6 +11,7 @@
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
class SparqlTermResolverTest {
@@ -68,4 +70,24 @@ void extractsLiteralLexicalForm() {
assertEquals("hello", resolver.unquoteLexical("\"hello\"@en"));
assertEquals("plain", resolver.unquoteLexical("plain"));
}
+
+ @Test
+ void decodesUnicodeInIrisAndDatatypes() {
+ var resolver = new SparqlTermResolver(new QueryPrologueAst(
+ List.of(new PrefixDeclarationAst("ex:", new IriAst("http://example.org/A/"))),
+ new IriAst("http://example.org/B/")));
+ assertEquals("http://example.org/A/name", resolver.resolveIri("ex:name"));
+ assertEquals("http://example.org/B/C", resolver.resolveIri("<\\u0043>"));
+ assertEquals("http://example.org/😀", resolver.resolveIri(""));
+ assertEquals("http://example.org/A", resolver.normalizeDatatypeIri(""));
+ }
+
+ @Test
+ void rejectsNonScalarUnicodeInIris() {
+ var resolver = new SparqlTermResolver(null);
+ for (String escape : List.of("uD800", "U0000DFFF", "U00110000", "UFFFFFFFF")) {
+ assertThrows(QuerySyntaxException.class,
+ () -> resolver.resolveIri(""));
+ }
+ }
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
index 0407d7d49..3d98880af 100644
--- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java
@@ -158,4 +158,66 @@ void constructWithVariablesProducesConcreteStatements() {
assertNotNull(stmts.getFirst().getSubject());
assertNotNull(stmts.getFirst().getObject());
}
+
+ @Test
+ void generatedBlankNodesDoNotAliasDatasetNodes() {
+ for (int i = 0; i < 32; i++) {
+ insert(valueFactory.createBNode("cb" + i), iri(KNOWS), iri(BOB));
+ }
+ var statements = collect("CONSTRUCT { _:b ?s } WHERE { ?s ?p ?o }");
+ for (Statement statement : statements) {
+ assertNotEquals(statement.getSubject(), statement.getObject());
+ }
+ assertEquals(statements.size(), statements.stream().map(Statement::getSubject).distinct().count());
+ }
+
+ @Test
+ void expandsNestedCollections() {
+ var statements = collect("CONSTRUCT { (()) } WHERE {}");
+ assertEquals(5, statements.size());
+ var firstValues = statements.stream()
+ .filter(s -> s.getPredicate().stringValue().endsWith("#first"))
+ .map(Statement::getObject).toList();
+ assertEquals(2, firstValues.size());
+ assertTrue(firstValues.contains(iri("http://example.org/a")));
+ assertEquals(1, firstValues.stream().filter(BNode.class::isInstance).count());
+ }
+
+ @Test
+ void expandsNestedPropertyListsInObjectPosition() {
+ var statements = collect("CONSTRUCT { [ [ 1 ] ] } WHERE {}");
+ assertEquals(3, statements.size());
+ var outer = statements.stream().filter(s -> s.getSubject().equals(iri("http://example.org/s"))).findFirst().orElseThrow();
+ var middle = statements.stream().filter(s -> s.getSubject().equals(outer.getObject())).findFirst().orElseThrow();
+ assertInstanceOf(BNode.class, middle.getObject());
+ assertTrue(statements.stream().anyMatch(s -> s.getSubject().equals(middle.getObject())));
+ }
+
+ @Test
+ void decodesUnicodeIriInTemplateAndWhere() {
+ var statements = collect("CONSTRUCT { ?s ?o } WHERE { ?s ?o }");
+ assertEquals(2, statements.size());
+ assertTrue(statements.stream().allMatch(s -> s.getPredicate().equals(iri("http://example.org/A"))));
+ }
+
+ @Test
+ void preservesEscapedBackslashBeforeSurrogateText() {
+ var statements = collect("CONSTRUCT { \"\\\\uD800\" } WHERE {}");
+ assertEquals(1, statements.size());
+ assertEquals("\\uD800", statements.getFirst().getObject().stringValue());
+ }
+
+ @Test
+ void decodesPrologueBeforeResolvingRelativeIris() {
+ for (String query : List.of(
+ "BASE <\\u0068ttp://example.org/> CONSTRUCT { } WHERE {}",
+ "PREFIX ex: <\\u0068ttp://example.org/> CONSTRUCT { ex:s ex:p ex:o } WHERE {}",
+ "BASE PREFIX ex: <../> CONSTRUCT { ex:s ex:p ex:o } WHERE {}")) {
+ var statements = collect(query);
+ assertEquals(1, statements.size(), query);
+ assertEquals(iri("http://example.org/s"), statements.getFirst().getSubject(), query);
+ assertEquals(iri("http://example.org/p"), statements.getFirst().getPredicate(), query);
+ assertEquals(iri("http://example.org/o"), statements.getFirst().getObject(), query);
+ }
+ }
}