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..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,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; @@ -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; @@ -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)))) { @@ -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) { @@ -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; - } } 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..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 @@ -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; @@ -307,11 +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); - 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; @@ -330,16 +345,35 @@ 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, Set 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; 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 ebe72c83c..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 @@ -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,50 @@ 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); + if (next == '\\') { + i += 2; + continue; + } + 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 +277,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..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}. @@ -68,15 +66,82 @@ 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 = expandTriplesNode(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 expandTriplesNode(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 expandCollection(ctx.collection(), inConstructWhere); + } + 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 expandCollection(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 = termFromConstructGraphNode(nodes.get(i), inConstructWhere); + 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; + } } - TermAst subject = queryBuilder().termFromVarOrTerm(ctx.varOrTerm()); - var propertyList = ctx.propertyListNotEmpty(); + 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/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/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 new file mode 100644 index 000000000..3d98880af --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ConstructBlankNodesTest.java @@ -0,0 +1,223 @@ +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()); + } + + @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); + } + } +} 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)); + } +}