diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdge.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdge.java new file mode 100644 index 000000000..976165ecf --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdge.java @@ -0,0 +1,87 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.kgram.api.core.Edge; +import fr.inria.corese.core.next.query.kgram.api.core.Node; + +import java.util.Objects; + +/** + * A query-side {@link Edge} produced by the WHERE compiler from a + * {@link fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst}. + */ +final class AstBackedEdge implements Edge { + + private final Node subject; + private final Node predicate; + private final Node object; + private final Node graph; + + AstBackedEdge(Node subject, Node predicate, Node object) { + this(subject, predicate, object, null); + } + + AstBackedEdge(Node subject, Node predicate, Node object, Node graph) { + this.subject = Objects.requireNonNull(subject, "subject"); + this.predicate = Objects.requireNonNull(predicate, "predicate"); + this.object = Objects.requireNonNull(object, "object"); + this.graph = graph; + } + + @Override + public Node getNode(int i) { + return switch (i) { + case 0 -> subject; + case 1 -> object; + default -> throw new IndexOutOfBoundsException( + "Triple pattern edge has nodes 0 (subject) and 1 (object), got: " + i); + }; + } + + @Override + public Node getProperty() { + return predicate; + } + + @Override + public Node getEdgeVariable() { + return predicate.isVariable() ? predicate : null; + } + + @Override + public String getEdgeLabel() { + return predicate.getLabel(); + } + + /** + * The graph this triple pattern is matched in, or {@code null} for the default graph. + * + */ + @Override + public Node getGraph() { + return graph; + } + + @Override + public boolean contains(Node node) { + if (node == null) { + return false; + } + for (int i = 0; i < nbNode(); i++) { + Node n = getNode(i); + if (n != null && (n == node || n.same(node))) { + return true; + } + } + return false; + } + + @Override + public Edge getEdge() { + return this; + } + + @Override + public String toString() { + return subject + " " + predicate + " " + object; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..635a81392 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java @@ -0,0 +1,162 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.impl.sparql.ast.*; +import fr.inria.corese.core.next.query.kgram.api.core.Edge; +import fr.inria.corese.core.next.query.kgram.api.core.ExpType.Type; +import fr.inria.corese.core.next.query.kgram.api.core.Filter; +import fr.inria.corese.core.next.query.kgram.api.core.Node; +import fr.inria.corese.core.next.query.kgram.core.Exp; +import fr.inria.corese.core.next.query.kgram.core.Query; +import fr.inria.corese.core.next.query.kgram.tool.NodeImpl; +import fr.inria.corese.core.sparql.triple.parser.Atom; +import fr.inria.corese.core.sparql.triple.parser.Expression; + +import java.util.Objects; + +/** + * Compiles the content of a SPARQL {@code WHERE} clause — a tree of + * {@link PatternAst} nodes — into a KGRAM {@link Exp} body that the engine can + * evaluate. + */ +public final class WhereCompiler { + + public WhereCompiler() { + } + + public Exp compile(GroupGraphPatternAst where) { + Objects.requireNonNull(where, "where"); + return compileGroup(where); + } + + /** + * Dispatch a single {@link PatternAst} element to its KGRAM {@link Exp}. + */ + public Exp compile(PatternAst pattern) { + Objects.requireNonNull(pattern, "pattern"); + return switch (pattern) { + case BgpAst bgp -> compileBgp(bgp); + case FilterAst filter -> compileFilter(filter); + case UnionAst union -> compileUnion(union); + case OptionalAst optional -> compileOptional(optional); + case MinusAst minus -> compileMinus(minus); + case BindAst bind -> compileBind(bind); + case ServiceAst service -> compileService(service); + case GroupGraphPatternAst group -> compileGroup(group); + default -> throw new UnsupportedOperationException( + "WHERE compilation not yet supported for: " + pattern.getClass().getSimpleName()); + }; + } + + + /** + * {@code { e1 e2 ... en }} becomes an {@code AND} of the compiled elements. + */ + private Exp compileGroup(GroupGraphPatternAst group) { + Exp body = Exp.create(Type.AND); + for (PatternAst element : group.patterns()) { + if (element instanceof OptionalAst(PatternAst ast)) { + Exp left = body; + Exp right = compile(ast); + Exp optionalExp = Exp.create(Type.OPTIONAL, left, right); + body = Exp.create(Type.AND); + body.add(optionalExp); + } else if (element instanceof MinusAst(GroupGraphPatternAst pattern)) { + Exp left = body; + Exp right = compile(pattern); + Exp minusExp = Exp.create(Type.MINUS, left, right); + body = Exp.create(Type.AND); + body.add(minusExp); + } else { + body.add(compile(element)); + } + } + return body; + } + + + private Exp compileBgp(BgpAst bgp) { + Exp pattern = Exp.create(Type.BGP); + for (TriplePatternAst triple : bgp.triples()) { + pattern.add(toEdge(triple)); + } + return pattern; + } + + private Edge toEdge(TriplePatternAst triple) { + Node subject = toNode(triple.subject()); + Node predicate = toNode(triple.predicate()); + Node object = toNode(triple.object()); + return new AstBackedEdge(subject, predicate, object); + } + + private Node toNode(TermAst term) { + Expression expression = SparqlAstToExpression.convert(term); + if (expression instanceof Atom atom) { + return new NodeImpl(atom); + } + throw new IllegalArgumentException( + "A triple-pattern term must be a variable, IRI or literal, got: " + + term.getClass().getSimpleName()); + } + + + private Exp compileFilter(FilterAst filter) { + Filter nextFilter = SparqlAstToExpression.toNextFilter(filter); + return Exp.create(Type.FILTER, nextFilter); + } + + + private Exp compileUnion(UnionAst union) { + Exp left = compile(union.left()); + Exp right = compile(union.right()); + return Exp.create(Type.UNION, left, right); + } + + + /** + * Compiles a bare {@link OptionalAst}, i.e. one that is not folded with a + * preceding pattern by {@link #compileGroup(GroupGraphPatternAst)} (for + * instance a lone {@code { OPTIONAL { ... } }}). The mandatory left part is + * then the empty pattern, matching SPARQL's {@code {} OPTIONAL { ... }}. + */ + private Exp compileOptional(OptionalAst optional) { + Exp left = Exp.create(Type.AND); + Exp right = compile(optional.ast()); + return Exp.create(Type.OPTIONAL, left, right); + } + + /** + * Compiles a bare {@link MinusAst} not folded with a preceding pattern. + * The mandatory left part is the empty pattern, matching {@code {} MINUS { ... }}. + */ + private Exp compileMinus(MinusAst minus) { + Exp left = Exp.create(Type.AND); + Exp right = compile(minus.pattern()); + return Exp.create(Type.MINUS, left, right); + } + + /** + * Compiles {@code BIND(expression AS ?var)} into a KGRAM {@link Exp}. + */ + private Exp compileBind(BindAst bind) { + Filter filter = SparqlAstToExpression.toNextFilter(bind.expression()); + Node variable = toNode(bind.variable()); + Exp exp = Exp.create(Type.BIND); + exp.setFilter(filter); + exp.setFunctional(filter.isFunctional()); + exp.setNode(variable); + return exp; + } + + /** + * Compiles {@code SERVICE { ... }} into a KGRAM {@link Exp}. + */ + private Exp compileService(ServiceAst service) { + Node endpoint = toNode(service.endpoint()); + Exp endpointNode = Exp.create(Type.NODE, endpoint); + Query body = Query.create(compile(service.pattern())); + Exp exp = Exp.create(Type.SERVICE, endpointNode, body); + exp.setSilent(service.silent()); + return exp; + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/sparql/triple/parser/Term.java b/src/main/java/fr/inria/corese/core/sparql/triple/parser/Term.java index 5db3a3436..b778ca2f2 100755 --- a/src/main/java/fr/inria/corese/core/sparql/triple/parser/Term.java +++ b/src/main/java/fr/inria/corese/core/sparql/triple/parser/Term.java @@ -1466,7 +1466,11 @@ public boolean isRecAggregate() { if (isAggregate(getLabel())) { return true; } - for (Expr exp : getExpList()) { + List expList = getExpList(); + if (expList == null) { + return false; + } + for (Expr exp : expList) { if (exp.isRecAggregate()) { return true; } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdgeTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdgeTest.java new file mode 100644 index 000000000..4bf4f3ac4 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstBackedEdgeTest.java @@ -0,0 +1,90 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.kgram.api.core.Node; +import fr.inria.corese.core.next.query.kgram.tool.NodeImpl; +import fr.inria.corese.core.sparql.triple.parser.Constant; +import fr.inria.corese.core.sparql.triple.parser.Variable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("AstBackedEdge: contains(), getEdgeVariable() and getGraph()") +class AstBackedEdgeTest { + + @Test + @DisplayName("contains() recognises the subject and the object") + void edgeContainsSubjectAndObject() { + Node subject = new NodeImpl(Variable.create("s")); + Node predicate = new NodeImpl(Variable.create("p")); + Node object = new NodeImpl(Variable.create("o")); + + AstBackedEdge edge = new AstBackedEdge(subject, predicate, object); + + assertTrue(edge.contains(subject)); + assertTrue(edge.contains(object)); + } + + @Test + @DisplayName("contains() recognises a variable by name (not only the same instance)") + void edgeContainsByVariableName() { + AstBackedEdge edge = new AstBackedEdge( + new NodeImpl(Variable.create("s")), + new NodeImpl(Variable.create("p")), + new NodeImpl(Variable.create("o"))); + + assertTrue(edge.contains(new NodeImpl(Variable.create("s")))); + assertTrue(edge.contains(new NodeImpl(Variable.create("o")))); + } + + @Test + @DisplayName("contains() returns false for an absent variable and for null") + void edgeDoesNotContainOtherNode() { + AstBackedEdge edge = new AstBackedEdge( + new NodeImpl(Variable.create("s")), + new NodeImpl(Variable.create("p")), + new NodeImpl(Variable.create("o"))); + + assertFalse(edge.contains(new NodeImpl(Variable.create("x")))); + assertFalse(edge.contains(null)); + } + + @Test + @DisplayName("getEdgeVariable() exposes the predicate when it is a variable, null otherwise") + void edgeVariableExposedOnlyForVariablePredicate() { + Node predicateVar = new NodeImpl(Variable.create("p")); + AstBackedEdge withVarPredicate = new AstBackedEdge( + new NodeImpl(Variable.create("s")), predicateVar, new NodeImpl(Variable.create("o"))); + assertSame(predicateVar, withVarPredicate.getEdgeVariable()); + + AstBackedEdge withIriPredicate = new AstBackedEdge( + new NodeImpl(Variable.create("s")), + new NodeImpl(Constant.createResource("http://example.org/p")), + new NodeImpl(Variable.create("o"))); + assertNull(withIriPredicate.getEdgeVariable()); + } + + @Test + @DisplayName("getGraph() is null for the default graph (engine represents it as null)") + void defaultGraphIsNull() { + AstBackedEdge edge = new AstBackedEdge( + new NodeImpl(Variable.create("s")), + new NodeImpl(Variable.create("p")), + new NodeImpl(Variable.create("o"))); + + assertNull(edge.getGraph()); + } + + @Test + @DisplayName("getGraph() returns the graph node passed to the constructor") + void explicitGraphIsReturned() { + Node graph = new NodeImpl(Constant.createResource("http://example.org/g")); + AstBackedEdge edge = new AstBackedEdge( + new NodeImpl(Variable.create("s")), + new NodeImpl(Variable.create("p")), + new NodeImpl(Variable.create("o")), + graph); + + assertSame(graph, edge.getGraph()); + } +} \ No newline at end of file diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompilerTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompilerTest.java new file mode 100644 index 000000000..b4c6e5697 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompilerTest.java @@ -0,0 +1,181 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.impl.sparql.ast.*; +import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.FunctionCallAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.GreaterThanAst; +import fr.inria.corese.core.next.query.kgram.core.Exp; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class WhereCompilerTest { + + private final WhereCompiler compiler = new WhereCompiler(); + + + private static GroupGraphPatternAst group(PatternAst... elements) { + return new GroupGraphPatternAst(List.of(elements)); + } + + + @Test + @DisplayName("OPTIONAL as a left join (first = mandatory part, rest = optional part)") + void compilesOptionalAsLeftJoin() { + BgpAst mandatory = new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))); + BgpAst optionalBgp = new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("q"), new VarAst("z")))); + + Exp body = compiler.compile(group(mandatory, new OptionalAst(optionalBgp))); + + assertTrue(body.isAnd()); + Exp optional = body.get(0); + assertTrue(optional.isOptional(), "OPTIONAL node"); + assertTrue(optional.first().isAnd(), "mandatory left part is the preceding group"); + assertTrue(optional.first().get(0).isBGP()); + assertTrue(optional.rest().isBGP(), "optional right part is the optional body"); + } + + + @Test + @DisplayName("UNION of its two branches") + void compilesUnion() { + UnionAst union = new UnionAst( + group(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o"))))), + group(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("q"), new VarAst("z")))))); + + Exp body = compiler.compile(group(union)); + + Exp unionExp = body.get(0); + assertTrue(unionExp.isUnion()); + assertTrue(unionExp.first().isAnd()); + assertTrue(unionExp.rest().isAnd()); + } + + + @Test + @DisplayName("FILTER added after the BGP in the group") + void compilesFilter() { + BgpAst bgp = new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))); + FilterAst filter = new FilterAst(new GreaterThanAst(List.of( + new VarAst("o"), new LiteralAst("5", null, null)))); + + Exp body = compiler.compile(group(bgp, filter)); + + assertEquals(2, body.size()); + assertTrue(body.get(0).isBGP()); + assertTrue(body.get(1).isFilter(), "second element is a FILTER"); + } + + + @Test + @DisplayName("MINUS folded with the preceding pattern") + void compilesMinus() { + BgpAst main = new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))); + BgpAst subtracted = new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("q"), new VarAst("z")))); + + Exp body = compiler.compile(group(main, new MinusAst(group(subtracted)))); + + assertTrue(body.isAnd()); + Exp minusExp = body.get(0); + assertTrue(minusExp.isMinus(), "MINUS node"); + assertTrue(minusExp.first().isAnd(), "left part is the preceding pattern"); + assertTrue(minusExp.first().get(0).isBGP()); + assertTrue(minusExp.rest().isAnd(), "right part is the MINUS body"); + } + + + @Test + @DisplayName("BIND produces an Exp with a filter and a target variable node") + void compilesBind() { + BindAst bind = new BindAst(new VarAst("x"), new VarAst("y")); + + Exp body = compiler.compile(group(bind)); + + assertEquals(1, body.size()); + Exp bindExp = body.get(0); + assertTrue(bindExp.isBind(), "BIND node"); + assertNotNull(bindExp.getFilter(), "filter carries the expression"); + assertNotNull(bindExp.getNode(), "node carries the target variable"); + } + + @Test + @DisplayName("SERVICE produces an Exp with an endpoint and a body") + void compilesService() { + ServiceAst service = new ServiceAst( + new IriAst(""), + false, + group(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))))); + + Exp body = compiler.compile(group(service)); + + Exp serviceExp = body.get(0); + assertTrue(serviceExp.isService(), "SERVICE node"); + assertFalse(serviceExp.isSilent(), "no SILENT flag"); + assertTrue(serviceExp.rest().isQuery(), "the body is a Query"); + assertTrue(serviceExp.rest().getQuery().getBody().isAnd(), "the Query body is an AND group"); + } + + @Test + @DisplayName("SERVICE rest() exposes a Query for the runtime (exp.rest().getQuery() != null)") + void serviceRestShouldExposeAQueryForRuntime() { + ServiceAst service = new ServiceAst( + new IriAst(""), + false, + group(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))))); + + Exp serviceExp = new WhereCompiler().compile(service); + + assertTrue(serviceExp.isService()); + assertNotNull(serviceExp.rest().getQuery(), "SERVICE runtime expects a query body"); + } + + @Test + @DisplayName("BGP ?s ?p ?o — variable predicate exposed via getEdgeVariable()") + void edgeExposesVariablePredicateAsEdgeVariable() { + Exp bgp = new WhereCompiler().compile(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o"))))); + + Exp edgeExp = bgp.get(0); + assertNotNull(edgeExp.getEdge().getEdgeVariable(), "variable predicate should be exposed as edge variable"); + assertTrue(edgeExp.getEdge().getEdgeVariable().isVariable()); + } + + @Test + @DisplayName("BIND propagates the filter's isFunctional flag (e.g. unnest)") + void bindShouldPropagateFunctionalFlag() { + BindAst bind = new BindAst( + new FunctionCallAst(new IriAst(""), List.of(new VarAst("x"))), + new VarAst("y")); + + Exp bindExp = new WhereCompiler().compile(bind); + + assertTrue(bindExp.getFilter().isFunctional()); + assertTrue(bindExp.isFunctional(), "BIND exp should propagate functional flag"); + } + + @Test + @DisplayName("SERVICE SILENT sets the isSilent flag") + void compilesServiceSilent() { + ServiceAst service = new ServiceAst( + new IriAst(""), + true, + group(new BgpAst(List.of( + new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")))))); + + Exp serviceExp = compiler.compile(service); + + assertTrue(serviceExp.isService()); + assertTrue(serviceExp.isSilent(), "SILENT flag enabled"); + } +} \ No newline at end of file