diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/common/util/IRIUtils.java b/src/main/java/fr/inria/corese/core/next/data/impl/common/util/IRIUtils.java index 00a245a59..0198a24e0 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/common/util/IRIUtils.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/common/util/IRIUtils.java @@ -126,7 +126,7 @@ public static String guessLocalName(String iri) { /** * Detects if an IRI is absolute according to the REGEX given in the recommendation RFC3987 - * @param iri any uri (expecting to be the content between < and > + * @param iri any uri (expecting to be the content between < and >) * @return true if it is compliant with RFC3987. May accept the prefixed for of uri, as there is no way to * distinguish a prefix from a protocol */ diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java index 78a10a5df..ece60a4d7 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java @@ -987,7 +987,14 @@ public TermAst termFromBuiltInCall(fr.inria.corese.core.next.impl.parser.antlr.S return new ExistsAst(popCapturedExistsPattern()); } else if (ctx.notExistsFunc() != null) { return new NotExistsAst(popCapturedExistsPattern()); - } else if (ctx.expression() != null) { + } else if (ctx.regexExpression() != null) { + return termFromRegex(ctx.regexExpression()); + } else if (ctx.BOUND() != null) { + return this.createConstraint(ASTConstants.FUNCTION_CALL.BOUND, List.of(this.var(ctx.var_().getText()))); + } else if (ctx.CONCAT() != null) { + List args = ctx.expression().stream().map(this::termFromExpression).toList(); + return new FunctionCallAst(new IriAst("CONCAT"), args); + } else if (ctx.expression() != null && !ctx.expression().isEmpty()) { List args = ctx.expression().stream().map(this::termFromExpression).toList(); if (ctx.STR() != null) { return this.createConstraint(ASTConstants.FUNCTION_CALL.STR, args); @@ -1005,12 +1012,8 @@ public TermAst termFromBuiltInCall(fr.inria.corese.core.next.impl.parser.antlr.S return this.createConstraint(ASTConstants.FUNCTION_CALL.IS_BLANK, args); } else if (ctx.IS_LITERAL() != null) { return this.createConstraint(ASTConstants.FUNCTION_CALL.IS_LITERAL, args); - } else if (ctx.BOUND() != null) { - return this.createConstraint(ASTConstants.FUNCTION_CALL.BOUND, List.of(this.var(ctx.var_().getText()))); - } else if (ctx.regexExpression() != null) { - return termFromRegex(ctx.regexExpression()); } else { - throw new QueryEvaluationException("Unexpected function for a BuiltInCall for token " + ctx.getText()); + throw new QueryEvaluationException("Unexpected function for a BuiltInCall for token " + ctx.getText()); } } else { throw new QueryEvaluationException("Unable to resolve BuiltInCall for token " + ctx.getText()); @@ -1237,4 +1240,26 @@ public List termListFromObjectListPath(SparqlParser.ObjectListPathConte } return out; } + + /** + * Adds a BIND clause to the current group. + * + * @throws QueryValidationException if the variable introduced by BIND is already visible + * in the group graph pattern up to this point, as required + * by the SPARQL 1.1 specification. + */ + public void addBind(BindAst bind) { + if (!this.hasCurrentGroup()) { + return; + } + List current = this.currentGroup(); + Set alreadyVisible = variableScopeAnalyzer + .collectVisibleVariables(new GroupGraphPatternAst(current)); + if (alreadyVisible.contains(bind.variable().name())) { + throw new QueryValidationException( + "Variable ?" + bind.variable().name() + + " used in BIND is already declared in the same group graph pattern"); + } + current.add(bind); + } } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java index f2a2c0b00..b3d42b3cb 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java @@ -189,4 +189,9 @@ public void enterExistsFunc(SparqlParser.ExistsFuncContext ctx) { public void enterNotExistsFunc(SparqlParser.NotExistsFuncContext ctx) { for (var d : delegates) d.enterNotExistsFunc(ctx); } + + @Override + public void exitBind(SparqlParser.BindContext ctx) { + for (var d : delegates) d.exitBind(ctx); + } } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlParser.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlParser.java index 097fd9b79..bf6972ffc 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlParser.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlParser.java @@ -118,7 +118,8 @@ public QueryAst parse(Reader reader, String baseIRI) { new UnionFeature(builder), new DescribeQueryFeature(builder), new DatasetClauseFeature(builder), - new PrologueFeature(builder) + new PrologueFeature(builder), + new BindFeature(builder) )); walker.walk(listener, tree); diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/VariableScopeAnalyzer.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/VariableScopeAnalyzer.java index f3ab4b842..443a8f4b7 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/VariableScopeAnalyzer.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/VariableScopeAnalyzer.java @@ -1,26 +1,15 @@ package fr.inria.corese.core.next.query.impl.parser; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -import fr.inria.corese.core.next.query.impl.sparql.ast.BgpAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.ConstraintAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.FilterAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.GroupGraphPatternAst; -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.OptionalAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.PatternAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.UnionAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.*; import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.BinaryConstraintAst; import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.FunctionCallAst; import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.TrinaryRegexAst; import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.UnaryConstraintAst; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + /** * Collects visible and referenced variables from the next SPARQL AST. * A visible variable is introduced by a graph pattern in the WHERE clause and @@ -92,6 +81,8 @@ case UnionAst(GroupGraphPatternAst left, GroupGraphPatternAst right) -> { collectVisibleVariables(left, visibleVariables); collectVisibleVariables(right, visibleVariables); } + case BindAst(TermAst expression, VarAst variable) -> + visibleVariables.add(variable.name()); case FilterAst ignored -> { // FILTER does not make a variable visible by itself. diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BindFeature.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BindFeature.java new file mode 100644 index 000000000..15d93cb70 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BindFeature.java @@ -0,0 +1,24 @@ +package fr.inria.corese.core.next.query.impl.parser.listener; + +import fr.inria.corese.core.next.impl.parser.antlr.SparqlParser; +import fr.inria.corese.core.next.query.impl.parser.SparqlAstBuilder; +import fr.inria.corese.core.next.query.impl.sparql.ast.BindAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; + +/** + * SPARQL {@code BIND} feature + */ +public class BindFeature extends AbstractSparqlFeature { + + public BindFeature(SparqlAstBuilder builder) { + super(builder); + } + + @Override + public void exitBind(SparqlParser.BindContext ctx) { + TermAst expression = builder().termFromExpression(ctx.expression()); + VarAst variable = (VarAst) builder().var(ctx.var_().getText()); + builder().addBind(new BindAst(expression, variable)); + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/BindAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/BindAst.java new file mode 100644 index 000000000..2fea14899 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/BindAst.java @@ -0,0 +1,6 @@ +package fr.inria.corese.core.next.query.impl.sparql.ast; + +/** + * BIND(expression AS ?var) clause in SPARQL 1.1 + */ +public record BindAst(TermAst expression, VarAst variable) implements PatternAst {} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/PatternAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/PatternAst.java index 8ad564e25..fc70186f8 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/PatternAst.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/PatternAst.java @@ -1,7 +1,7 @@ package fr.inria.corese.core.next.query.impl.sparql.ast; /** - * Element of a group graph pattern (BGP, optional, union, etc.). + * Element of a group graph pattern (BGP, optional, union, Bind, etc.). */ -public sealed interface PatternAst permits BgpAst, FilterAst, GroupGraphPatternAst, OptionalAst, UnionAst { +public sealed interface PatternAst permits BgpAst, BindAst, FilterAst, GroupGraphPatternAst, OptionalAst, UnionAst { } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/SelectQueryAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/SelectQueryAst.java index fbcf02164..947107f81 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/SelectQueryAst.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/SelectQueryAst.java @@ -6,7 +6,7 @@ * Abstract Syntax Tree (AST) representation of a SPARQL {@code SELECT} query. * Holds the projection (SELECT * or SELECT ?v1 ?v2 ...) and the WHERE clause. *

- * {@link #prologue()} captures PREFIX/BASE for SELECT; {@link #prefixHandler()} is derived from it + * {@link #prologue()} captures PREFIX/BASE for SELECT; * for {@link QueryAst} compatibility. */ public record SelectQueryAst(ProjectionAst projection, DatasetClauseAst datasetClause, GroupGraphPatternAst whereClause, SolutionModifierAst solutionModifier, QueryPrologueAst prologue) implements QueryAst { diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserBindTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserBindTest.java new file mode 100644 index 000000000..e80bd4935 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserBindTest.java @@ -0,0 +1,211 @@ +package fr.inria.corese.core.next.query.impl.parser; + +import fr.inria.corese.core.next.query.impl.sparql.ast.*; +import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.FunctionCallAst; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("SPARQL 1.1 - Parser and AST : BIND") +class SparqlParserBindTest extends AbstractSparqlParserFeatureTest { + + @Test + @DisplayName("BIND(?s AS ?x) — variable expression") + void shouldParseBindWithVariable() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND(?s AS ?x) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + PatternAst last = where.patterns().getLast(); + assertInstanceOf(BindAst.class, last); + + BindAst bindAst = (BindAst) last; + assertInstanceOf(VarAst.class, bindAst.expression()); + assertEquals("s", ((VarAst) bindAst.expression()).name()); + assertEquals("x", bindAst.variable().name()); + } + + @Test + @DisplayName("BIND(CONCAT(?a, ?b) AS ?c) — built-in function expression") + void shouldParseBindWithConcat() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND(CONCAT(?a, ?b) AS ?c) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + PatternAst last = where.patterns().getLast(); + assertInstanceOf(BindAst.class, last); + + BindAst bindAst = (BindAst) last; + assertInstanceOf(FunctionCallAst.class, bindAst.expression()); + assertEquals("c", bindAst.variable().name()); + + FunctionCallAst concatAst = (FunctionCallAst) bindAst.expression(); + assertEquals("CONCAT", ((IriAst) concatAst.functionName()).raw()); + assertEquals(2, concatAst.arguments().size()); + assertEquals("a", ((VarAst) concatAst.arguments().getFirst()).name()); + assertEquals("b", ((VarAst) concatAst.arguments().getLast()).name()); + } + + @Test + @DisplayName("BIND(?x + 1 AS ?y) — arithmetic expression") + void shouldParseBindWithArithmetic() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND(?x + 1 AS ?y) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + PatternAst last = where.patterns().getLast(); + assertInstanceOf(BindAst.class, last); + + BindAst bindAst = (BindAst) last; + assertEquals("y", bindAst.variable().name()); + assertNotNull(bindAst.expression()); + } + + @Test + @DisplayName("BIND(\"hello\" AS ?label) — string literal expression") + void shouldParseBindWithStringLiteral() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND("hello" AS ?label) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + PatternAst last = where.patterns().getLast(); + assertInstanceOf(BindAst.class, last); + + BindAst bindAst = (BindAst) last; + assertInstanceOf(LiteralAst.class, bindAst.expression()); + assertEquals("label", bindAst.variable().name()); + assertEquals("\"hello\"", ((LiteralAst) bindAst.expression()).lexical()); + } + + @Test + @DisplayName("BIND( AS ?type) — IRI expression") + void shouldParseBindWithIri() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND( AS ?type) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + PatternAst last = where.patterns().getLast(); + assertInstanceOf(BindAst.class, last); + + BindAst bindAst = (BindAst) last; + assertInstanceOf(IriAst.class, bindAst.expression()); + assertEquals("type", bindAst.variable().name()); + assertEquals("", ((IriAst) bindAst.expression()).raw()); + } + + @Test + @DisplayName("Multiple BIND clauses in the same group") + void shouldParseMultipleBinds() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND(?s AS ?x) + BIND(?p AS ?y) + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(3, where.patterns().size()); + + assertInstanceOf(BgpAst.class, where.patterns().get(0)); + + BindAst first = assertInstanceOf(BindAst.class, where.patterns().get(1)); + assertEquals("x", first.variable().name()); + + BindAst second = assertInstanceOf(BindAst.class, where.patterns().get(2)); + assertEquals("y", second.variable().name()); + } + + @Test + @DisplayName("BIND inside an OPTIONAL block") + void shouldParseBindInsideOptional() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + OPTIONAL { BIND(?s AS ?x) } + } + """); + + assertNotNull(ast); + GroupGraphPatternAst where = ast.whereClause(); + assertEquals(2, where.patterns().size()); + + assertInstanceOf(BgpAst.class, where.patterns().get(0)); + + OptionalAst optional = assertInstanceOf(OptionalAst.class, where.patterns().get(1)); + GroupGraphPatternAst optionalGroup = assertInstanceOf(GroupGraphPatternAst.class, optional.ast()); + + assertEquals(1, optionalGroup.patterns().size()); + BindAst bind = assertInstanceOf(BindAst.class, optionalGroup.patterns().getFirst()); + assertEquals("x", bind.variable().name()); + } + + @Test + @DisplayName("SELECT ?x with BIND(... AS ?x) — BIND variable is visible in projection") + void shouldAcceptBindVariableInSelectProjection() { + SparqlParser parser = newParserDefault(); + + QueryAst ast = parser.parse(""" + SELECT ?x WHERE { + ?s ?p ?o . + BIND(?s AS ?x) + } + """); + + assertNotNull(ast); + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, ast); + assertEquals(1, select.projection().variables().size()); + assertEquals("x", select.projection().variables().getFirst().name()); + } +} \ No newline at end of file diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserValidationTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserValidationTest.java index 09531a94e..ac103fdb8 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserValidationTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/parser/SparqlParserValidationTest.java @@ -121,4 +121,42 @@ void shouldRejectInvalidProjection() { } + @Nested + class BindValidationTest { + + @Test + @DisplayName("Should reject BIND when variable is already introduced by a triple pattern") + void shouldRejectBindVariableAlreadyVisibleFromTriple() { + SparqlParser parser = newParserDefault(); + + QueryValidationException exception = assertThrows(QueryValidationException.class, () -> parser.parse(""" + SELECT * WHERE { + ?x ?p ?o . + BIND(?o AS ?x) + } + """)); + + assertEquals("Variable ?x used in BIND is already declared in the same group graph pattern", + exception.getMessage()); + } + + @Test + @DisplayName("Should reject BIND when variable is already introduced by a previous BIND") + void shouldRejectBindVariableAlreadyVisibleFromPreviousBind() { + SparqlParser parser = newParserDefault(); + + QueryValidationException exception = assertThrows(QueryValidationException.class, () -> parser.parse(""" + SELECT * WHERE { + ?s ?p ?o . + BIND(?s AS ?x) + BIND(?p AS ?x) + } + """)); + + assertEquals("Variable ?x used in BIND is already declared in the same group graph pattern", + exception.getMessage()); + } + + } + }