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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/antlr/SparqlLexer.g4
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ PIPE : '|';

// MISC

IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020'))* '>';
IRI_REF: '<' (~('<' | '>' | '"' | '{' | '}' | '|' | '^' | '\\' | '`' | '\u0000'..'\u0020') | UCHAR)* '>';

PNAME_NS: PN_PREFIX? ':';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> capturedInScope;
private final NextFilterFromAst filterView;
private int index = ExprType.UNBOUND;
private int subtype = ExprType.GLOBAL;
Expand All @@ -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);
}
Expand All @@ -53,6 +64,11 @@ WhereCompiler whereCompiler() {
return whereCompiler;
}

/** Returns the in-scope variables as they were when this expression was compiled. */
Set<String> capturedInScope() {
return capturedInScope;
}

@Override
public Filter getFilter() {
return filterView;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ public final class NextFilterFromAst implements Filter {
@Override
public List<String> 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<String> names = new LinkedHashSet<>();
Set<String> inScope = (owner.whereCompiler() != null)
? owner.whereCompiler().inScopeVariables()
: Set.of();
Set<String> inScope = owner.capturedInScope();
boolean recExist = owner.isRecExist();
owner.sourceAst().orElseThrow().accept(new AbstractAstVisitor() {
@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.impl.sparql.parser.SparqlIriEscapes;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
Expand All @@ -40,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;
Expand Down Expand Up @@ -85,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)))) {
Expand Down Expand Up @@ -162,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) {
Expand All @@ -183,35 +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()) {
result.appendCodePoint(Integer.parseInt(
value.substring(escapeIndex + 1, escapeIndex + 1 + length), 16));
return escapeIndex + 1 + length;
}
}
result.append(escape);
return escapeIndex + 1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@
import fr.inria.corese.core.next.storage.api.StorageManager;

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;
Expand Down Expand Up @@ -307,11 +311,22 @@ private List<Statement> buildConstructStatements(Query kgramQuery, Mappings mapp
ValueFactory factory = Values.factory();
List<Statement> statements = new ArrayList<>();

// Reserve every solution’s labels before instantiation, including later solutions.
Set<String> 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<String, Node> 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, 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;
Expand All @@ -330,16 +345,35 @@ private List<Statement> 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:
* <ul>
* <li>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.</li>
* <li>Regular variable nodes are looked up in the current mapping.</li>
* <li>Constant nodes are returned as-is.</li>
* </ul>
*
* @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<String, Node> blankNodeMap, Set<String> usedBlankLabels) {
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 -> {
Node blank;
do {
blank = NodeImpl.forValue(Values.factory().createBNode());
} while (!usedBlankLabels.add(blank.getLabel()));
return blank;
});
}
return mapping.getNode(templateNode);
}
return templateNode;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading