diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java index 8e0ac6508..62a60551f 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java @@ -287,7 +287,7 @@ private void applyProjection(Query query, ProjectionAst projection) { } else { selectExpressions = new ArrayList<>(); for (VarAst variable : projection.variables()) { - Node node = query.getExtNode(variable.name()); + Node node = visibleBodyNode(query, variable.name()); if (node == null) { throw new IllegalArgumentException( "Projected variable ?" + variable.name() + " is not visible in the compiled query body"); @@ -313,7 +313,7 @@ private List describeNodes(Query query, DescribeQueryAst describeQueryAst) List nodes = new ArrayList<>(); for (TermAst term : describeQueryAst.described()) { if (term instanceof VarAst(String name)) { - Node node = query.getExtNode(name); + Node node = visibleBodyNode(query, name); if (node == null) { throw new IllegalArgumentException( "DESCRIBE variable ?" + name + " is not visible in the compiled query body"); @@ -396,7 +396,7 @@ private Exp toOrderByExpression(Query query, OrderConditionAst orderCondition, i Exp selectExpression = query.getSelectExp(name); Node node = selectExpression != null ? selectExpression.getNode() - : query.getProperAndSubSelectNode(name); + : visibleBodyNode(query, name); if (node == null) { throw new IllegalArgumentException( "ORDER BY variable ?" + name + " is not visible in the compiled query"); @@ -429,6 +429,20 @@ private List selectNodeList(List selectExpressions) { return new ArrayList<>(selectNodes); } + /** + * Resolves only variables visible from the outer body scope. This deliberately + * excludes nodes collected from MINUS/EXISTS bodies, which KGRAM stores as query + * nodes for internal evaluation but which are not projectable SPARQL bindings. + */ + private Node visibleBodyNode(Query query, String name) { + for (Node node : query.selectNodesFromPattern()) { + if (node.getLabel().equals(name)) { + return node; + } + } + return null; + } + private void applyLimitOffset(Query query, SolutionModifierAst solutionModifier) { if (solutionModifier.hasLimit()) { query.setLimit(Math.toIntExact(solutionModifier.limit())); @@ -476,7 +490,7 @@ private Exp compileConstructTemplate(Query query, ConstructTemplateAst template) */ private Node constructNode(Query query, TermAst term) { if (term instanceof VarAst(String name)) { - Node bound = query.getExtNode(name); + Node bound = visibleBodyNode(query, name); return bound != null ? bound : toNode(term); } return toNode(term); diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderConstructTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderConstructTest.java index 0a4518ffe..038512d41 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderConstructTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderConstructTest.java @@ -24,6 +24,13 @@ private static GroupGraphPatternAst whereSPO() { return new GroupGraphPatternAst(List.of(new BgpAst(List.of(new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("o")))))); } + private static GroupGraphPatternAst whereWithMinusOnlyZ() { + return new GroupGraphPatternAst(List.of( + new BgpAst(List.of(new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("o")))), + new MinusAst(new GroupGraphPatternAst(List.of( + new BgpAst(List.of(new TriplePatternAst(new VarAst("x"), new VarAst("q"), new VarAst("z"))))))))); + } + private static ConstructTemplateAst template(TriplePatternAst... triples) { return new ConstructTemplateAst(List.of(triples)); } @@ -93,6 +100,22 @@ void templateOnlyVariableStaysFresh() { assertTrue(object.isVariable(), "template-only variable is still a variable node"); } + @Test + @DisplayName("Template variable that only occurs in MINUS stays fresh") + void templateMinusOnlyVariableStaysFresh() { + ConstructQueryAst construct = new ConstructQueryAst( + template(new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("z"))), + whereWithMinusOnlyZ()); + + Query query = builder.toNextQuery(construct); + + Node minusNode = query.getQueryNode("z"); + Node templateNode = query.getConstruct().first().getEdge().getNode(1); + assertNotNull(minusNode, "MINUS-only variable is stored as an internal query node"); + assertNotSame(minusNode, templateNode, "template ?z must not reuse the internal MINUS node"); + assertTrue(templateNode.isVariable(), "template-only ?z remains a variable node"); + } + @Test @DisplayName("Template IRIs become fixed (non-variable) nodes") void templateWithFixedResources() { @@ -146,4 +169,4 @@ void rejectsValuesClause() { void rejectsNull() { assertThrows(NullPointerException.class, () -> builder.toNextQuery((ConstructQueryAst) null)); } -} \ No newline at end of file +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderDescribeTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderDescribeTest.java index 68135af42..96cd4ef93 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderDescribeTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderDescribeTest.java @@ -24,6 +24,15 @@ private static GroupGraphPatternAst whereBindingX() { new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("o")))))); } + private static GroupGraphPatternAst whereWithMinusOnlyZ() { + return new GroupGraphPatternAst(List.of( + new BgpAst(List.of( + new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("o")))), + new MinusAst(new GroupGraphPatternAst(List.of( + new BgpAst(List.of( + new TriplePatternAst(new VarAst("x"), new VarAst("q"), new VarAst("z"))))))))); + } + @Test @DisplayName("Parser -> DescribeQueryAst -> Query lowers DESCRIBE to a construct-like query") void parserDescribeAstToQuery() { @@ -97,6 +106,15 @@ void rejectsDescribeVariableNotVisible() { assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(describe)); } + @Test + @DisplayName("DESCRIBE rejects a variable that only occurs in MINUS") + void rejectsDescribeMinusOnlyVariable() { + DescribeQueryAst describe = new DescribeQueryAst( + DatasetClauseAst.none(), List.of(new VarAst("z")), whereWithMinusOnlyZ()); + + assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(describe)); + } + @Test @DisplayName("FROM / FROM NAMED are applied to the query dataset") void appliesDatasetClause() { diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderMinusScopeTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderMinusScopeTest.java new file mode 100644 index 000000000..28cf1c596 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderMinusScopeTest.java @@ -0,0 +1,67 @@ +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.Node; +import fr.inria.corese.core.next.query.kgram.core.Exp; +import fr.inria.corese.core.next.query.kgram.core.Query; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CoreseAstQueryBuilderMinusScopeTest { + + private final WhereCompiler compiler = new WhereCompiler(); + + private static BgpAst bgp(String s, String p, String o) { + return new BgpAst(List.of( + new TriplePatternAst(new VarAst(s), new VarAst(p), new VarAst(o)))); + } + + private static boolean hasLabel(List nodes, String label) { + return nodes.stream().anyMatch(n -> label.equals(n.getLabel())); + } + + @Test + @DisplayName("{ ?s ?p ?o . MINUS { ?s ?q ?z } } : ?z (MINUS-only) is a query node, not a pattern node") + void minusRightSideVariablesStayOutOfOuterScope() { + Exp body = compiler.compile(new GroupGraphPatternAst(List.of( + bgp("s", "p", "o"), + new MinusAst(new GroupGraphPatternAst(List.of(bgp("s", "q", "z"))))))); + + Query query = Query.create(body); + query.collect(); + + // WHERE pattern variables are in scope (pattern nodes) + assertTrue(hasLabel(query.getPatternNodes(), "s"), "?s from WHERE is a pattern node"); + assertTrue(hasLabel(query.getPatternNodes(), "o"), "?o from WHERE is a pattern node"); + + // MINUS-only variable ?z binds nothing outside: it must NOT be a pattern node + assertFalse(hasLabel(query.getPatternNodes(), "z"), + "?z appears only in the MINUS body and must not leak into the outer pattern scope"); + + // it is collected in the query (minus/exists) node list instead + assertTrue(hasLabel(query.getQueryNodes(), "z"), + "?z from the MINUS body is stored as a query node (minus scope), stored separately"); + } + + @Test + @DisplayName("{ ?s ?p ?o . OPTIONAL { ?s ?q ?z } } : ?z (OPTIONAL-only) is a pattern node visible in the outer scope") + void optionalVariablesAreInOuterScope() { + Exp body = compiler.compile(new GroupGraphPatternAst(List.of( + bgp("s", "p", "o"), + new OptionalAst(new GroupGraphPatternAst(List.of(bgp("s", "q", "z"))))))); + + Query query = Query.create(body); + query.collect(); + + assertTrue(hasLabel(query.getPatternNodes(), "s"), "?s from WHERE is a pattern node"); + assertTrue(hasLabel(query.getPatternNodes(), "o"), "?o from WHERE is a pattern node"); + // OPTIONAL variables ARE in the outer scope (unlike MINUS) + assertTrue(hasLabel(query.getPatternNodes(), "z"), + "?z from OPTIONAL body must be visible in the outer pattern scope"); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderTest.java index 9b26f75ca..a493c21f5 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderTest.java @@ -5,7 +5,9 @@ import fr.inria.corese.core.next.query.impl.sparql.ast.DatasetClauseAst; 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.MinusAst; import fr.inria.corese.core.next.query.impl.sparql.ast.OrderConditionAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.PatternAst; import fr.inria.corese.core.next.query.impl.sparql.ast.ProjectionAsts; import fr.inria.corese.core.next.query.impl.sparql.ast.QueryAst; import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; @@ -23,6 +25,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -73,6 +76,43 @@ void buildsExplicitProjection() { assertEquals(List.of("o", "s"), labels(query.getSelect())); } + @Test + @DisplayName("SELECT * excludes variables that only occur in MINUS") + void selectStarExcludesMinusOnlyVariables() { + SelectQueryAst select = parseSelect("SELECT * WHERE { ?s ?p ?o . MINUS { ?s ?q ?z } }"); + + Query query = builder.toNextQuery(select); + + assertEquals(List.of("s", "p", "o"), labels(query.getSelect()), + "MINUS-only variables are stored separately and must not be visible to SELECT *"); + assertFalse(labels(query.getSelect()).contains("z")); + } + + @Test + @DisplayName("Explicit projection rejects a variable that only occurs in MINUS") + void explicitProjectionRejectsMinusOnlyVariable() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.of(List.of(new VarAst("z"))), + null, + group(bgp("s", "p", "o"), new MinusAst(group(bgp("s", "q", "z"))))); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(select)); + + assertEquals("Projected variable ?z is not visible in the compiled query body", error.getMessage()); + } + + @Test + @DisplayName("SELECT * includes variables introduced by OPTIONAL") + void selectStarIncludesOptionalVariables() { + SelectQueryAst select = parseSelect("SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s ?q ?z } }"); + + Query query = builder.toNextQuery(select); + + assertEquals(List.of("s", "p", "o", "q", "z"), labels(query.getSelect()), + "OPTIONAL variables remain visible in the outer query scope"); + } + @Test @DisplayName("DISTINCT, LIMIT and OFFSET are copied onto the next Query") void appliesSimpleSolutionModifiers() { @@ -134,6 +174,35 @@ void appliesExpressionOrderBy() { "non-variable ORDER BY expressions should be carried by a runtime filter"); } + @Test + @DisplayName("ORDER BY rejects a variable that only occurs in MINUS") + void orderByRejectsMinusOnlyVariable() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + null, + group(bgp("s", "p", "o"), new MinusAst(group(bgp("s", "q", "z")))), + SolutionModifierAst.withoutGroupBy(false, false, + List.of(new OrderConditionAst(ASTConstants.OrderDirection.ASC, new VarAst("z"))), + null, + null)); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(select)); + + assertEquals("ORDER BY variable ?z is not visible in the compiled query", error.getMessage()); + } + + @Test + @DisplayName("ORDER BY accepts a variable introduced by OPTIONAL") + void orderByAcceptsOptionalVariable() { + SelectQueryAst select = parseSelect("SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s ?q ?z } } ORDER BY ?z"); + + Query query = builder.toNextQuery(select); + + assertEquals(1, query.getOrderBy().size()); + assertEquals("z", query.getOrderBy().getFirst().getNode().getLabel()); + } + @Test @DisplayName("FROM and FROM NAMED are copied onto the next Query dataset") void appliesDatasetClauses() { @@ -197,6 +266,19 @@ private static GroupGraphPatternAst group(TriplePatternAst triple) { return new GroupGraphPatternAst(List.of(new BgpAst(List.of(triple)))); } + private static GroupGraphPatternAst group(PatternAst... elements) { + return new GroupGraphPatternAst(List.of(elements)); + } + + private static BgpAst bgp(String s, String p, String o) { + return new BgpAst(List.of( + new TriplePatternAst(new VarAst(s), new VarAst(p), new VarAst(o)))); + } + + private SelectQueryAst parseSelect(String query) { + return assertInstanceOf(SelectQueryAst.class, newParserDefault().parse(query)); + } + private static List labels(List nodes) { return nodes.stream().map(Node::getLabel).toList(); } 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 index f2a32ed97..495165013 100644 --- 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 @@ -1,5 +1,6 @@ package fr.inria.corese.core.next.query.impl.sparql.bridge; +import fr.inria.corese.core.next.query.impl.parser.AbstractSparqlParserFeatureTest; 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; @@ -12,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*; -class WhereCompilerTest { +class WhereCompilerTest extends AbstractSparqlParserFeatureTest { private final WhereCompiler compiler = new WhereCompiler(); @@ -94,6 +95,24 @@ void compilesMinus() { } + @Test + @DisplayName("OPTIONAL { BGP . FILTER } — the filter is compiled inside the optional right part") + void compilesOptionalWithFilter() { + FilterAst filter = new FilterAst(new GreaterThanAst(List.of( + new VarAst("z"), new LiteralAst("5", null, null)))); + GroupGraphPatternAst optionalBody = group(bgp("s", "q", "z"), filter); + + Exp body = compiler.compile(group(bgp("s", "p", "o"), new OptionalAst(optionalBody))); + + Exp optional = body.get(0); + assertTrue(optional.isOptional()); + Exp right = optional.rest(); + assertTrue(right.isAnd(), "optional body is a group"); + assertEquals(2, right.size(), "BGP + FILTER inside the optional body"); + assertTrue(right.get(0).isBGP()); + assertTrue(right.get(1).isFilter()); + } + @Test @DisplayName("BIND produces an Exp with a filter and a target variable node") void compilesBind() { @@ -196,4 +215,130 @@ void serviceBodyShouldBeMarkedAsServiceQuery() { assertTrue(serviceExp.rest().getQuery().isService(), "SERVICE body query should be marked as service"); } + + @Test + @DisplayName("Pattern after OPTIONAL stays a sibling: { P1 . OPTIONAL{P2} . P3 } -> AND[ OPTIONAL(AND[P1], P2), BGP(P3) ]") + void patternAfterOptionalStaysSibling() { + Exp body = compiler.compile(group( + bgp("s", "p", "o"), + new OptionalAst(bgp("s", "q", "z")), + bgp("s", "r", "w"))); + + assertTrue(body.isAnd()); + assertEquals(2, body.size(), "OPTIONAL and the trailing BGP are two siblings"); + + Exp optional = body.get(0); + assertTrue(optional.isOptional()); + assertTrue(optional.first().isAnd(), "mandatory left part is the preceding pattern"); + assertTrue(optional.first().get(0).isBGP()); + assertTrue(optional.rest().isBGP(), "optional right part is P2"); + + assertTrue(body.get(1).isBGP(), "P3 is a sibling of the OPTIONAL, not folded into it"); + } + + @Test + @DisplayName("Chained OPTIONALs left-associate: { P1 . OPTIONAL{P2} . OPTIONAL{P3} } -> OPTIONAL(OPTIONAL(P1,P2), P3)") + void chainedOptionalsLeftAssociate() { + Exp body = compiler.compile(group( + bgp("s", "p", "o"), + new OptionalAst(bgp("s", "q", "z")), + new OptionalAst(bgp("s", "r", "w")))); + + assertEquals(1, body.size()); + Exp outer = body.get(0); + assertTrue(outer.isOptional(), "outermost OPTIONAL"); + assertTrue(outer.rest().isBGP(), "outer optional part is P3"); + + Exp inner = outer.first().get(0); + assertTrue(inner.isOptional(), "left part is the inner OPTIONAL(P1, P2)"); + assertTrue(inner.rest().isBGP(), "inner optional part is P2"); + assertTrue(inner.first().get(0).isBGP(), "inner mandatory part is P1"); + } + + @Test + @DisplayName("Pattern after MINUS stays a sibling: { P1 . MINUS{P2} . P3 } -> AND[ MINUS(AND[P1], AND[P2]), BGP(P3) ]") + void patternAfterMinusStaysSibling() { + Exp body = compiler.compile(group( + bgp("s", "p", "o"), + new MinusAst(group(bgp("s", "q", "z"))), + bgp("s", "r", "w"))); + + assertTrue(body.isAnd()); + assertEquals(2, body.size()); + + Exp minus = body.get(0); + assertTrue(minus.isMinus()); + assertTrue(minus.first().isAnd(), "mandatory left part is the preceding pattern"); + assertTrue(minus.first().get(0).isBGP()); + assertTrue(minus.rest().isAnd(), "right part is the MINUS body"); + assertTrue(minus.rest().get(0).isBGP()); + + assertTrue(body.get(1).isBGP(), "P3 is a sibling of the MINUS"); + } + + @Test + @DisplayName("OPTIONAL first in the group has an empty mandatory left part: { OPTIONAL{P1} } -> OPTIONAL(AND[], P1)") + void optionalFirstHasEmptyMandatoryPart() { + Exp body = compiler.compile(group(new OptionalAst(bgp("s", "p", "o")))); + + assertEquals(1, body.size()); + Exp optional = body.get(0); + assertTrue(optional.isOptional()); + assertTrue(optional.first().isAnd()); + assertEquals(0, optional.first().size(), "mandatory left part is empty ({} OPTIONAL { ... })"); + assertTrue(optional.rest().isBGP()); + } + + @Test + @DisplayName("Bare OPTIONAL/MINUS via the pattern dispatcher use an empty mandatory left part") + void bareOptionalAndMinusUseEmptyLeft() { + Exp optional = compiler.compile(new OptionalAst(bgp("s", "p", "o"))); + assertTrue(optional.isOptional()); + assertTrue(optional.first().isAnd()); + assertEquals(0, optional.first().size(), "bare OPTIONAL: {} OPTIONAL { ... }"); + assertTrue(optional.rest().isBGP()); + + Exp minus = compiler.compile(new MinusAst(group(bgp("s", "p", "o")))); + assertTrue(minus.isMinus()); + assertTrue(minus.first().isAnd()); + assertEquals(0, minus.first().size(), "bare MINUS: {} MINUS { ... }"); + assertTrue(minus.rest().isAnd()); + } + + @Test + @DisplayName("Parser -> WhereCompiler: OPTIONAL compiles to an OPTIONAL Exp") + void parsedOptionalCompilesCorrectly() { + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, + newParserDefault().parse("SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s ?q ?z } }")); + + Exp body = compiler.compile(select.whereClause()); + + assertTrue(body.isAnd()); + Exp optional = body.get(0); + assertTrue(optional.isOptional(), "OPTIONAL compiled from parsed query"); + assertTrue(optional.first().isAnd(), "mandatory left part"); + assertTrue(optional.first().get(0).isBGP()); + assertTrue(optional.rest().isAnd(), "optional right part is the inner group"); + } + + @Test + @DisplayName("Parser -> WhereCompiler: MINUS compiles to a MINUS Exp") + void parsedMinusCompilesCorrectly() { + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, + newParserDefault().parse("SELECT * WHERE { ?s ?p ?o . MINUS { ?s ?q ?z } }")); + + Exp body = compiler.compile(select.whereClause()); + + assertTrue(body.isAnd()); + Exp minus = body.get(0); + assertTrue(minus.isMinus(), "MINUS compiled from parsed query"); + assertTrue(minus.first().isAnd(), "left part is the preceding pattern"); + assertTrue(minus.first().get(0).isBGP()); + assertTrue(minus.rest().isAnd(), "right part is the MINUS body"); + } + + private static BgpAst bgp(String s, String p, String o) { + return new BgpAst(List.of( + new TriplePatternAst(new VarAst(s), new VarAst(p), new VarAst(o)))); + } }