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 dfe2981df..d475e3008 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
@@ -36,11 +36,11 @@ public AstBackedExpr(Expression delegate) {
this(delegate, Optional.empty());
}
- public AstBackedExpr(Expression delegate, Optional sourceAst) {
- this.delegate = Objects.requireNonNull(delegate);
- this.sourceAst = sourceAst == null ? Optional.empty() : sourceAst;
- this.filterView = new NextFilterFromAst(this);
- }
+ public AstBackedExpr(Expression delegate, Optional sourceAst) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ this.sourceAst = Objects.requireNonNull(sourceAst, "sourceAst");
+ this.filterView = new NextFilterFromAst(this);
+ }
/**
* The underlying SPARQL interpreter {@link Expression} (triple.parser), for metadata and {@link Filter#getFilterExpression()}.
@@ -300,8 +300,8 @@ public IDatatype evalWE(Evaluator eval, BindingContext b, Environment env, Produ
}
private static Binding bindingFrom(BindingContext b) {
- if (b instanceof BindingAdapter ba) {
- return ba.delegate();
+ if (b instanceof BindingAdapter(Binding delegate1)) {
+ return delegate1;
}
if (b instanceof Binding binding) {
return binding;
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 3f5db08b8..ce4e43c57 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
@@ -4,6 +4,7 @@
import fr.inria.corese.core.next.query.impl.sparql.ast.ASTConstants;
import fr.inria.corese.core.next.query.impl.sparql.ast.ConstraintAst;
import fr.inria.corese.core.next.query.impl.sparql.ast.DatasetClauseAst;
+import fr.inria.corese.core.next.query.impl.sparql.ast.DescribeQueryAst;
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;
@@ -32,8 +33,8 @@
* Builds KGRAM {@code Exp} / {@code Query} structures from Corese-next query AST nodes.
*
* This bridge sits between the parsed SPARQL AST and the KGRAM runtime query model:
- * it does not execute queries, it translates syntax-level query forms ({@code ASK}, {@code SELECT})
- * into runtime-ready {@link Query}/{@link Exp} structures.
+ * it does not execute queries, it translates syntax-level query forms ({@code ASK}, {@code SELECT},
+ * {@code DESCRIBE}) into runtime-ready {@link Query}/{@link Exp} structures.
*/
public final class CoreseAstQueryBuilder {
@@ -100,6 +101,30 @@ public Query toNextQuery(SelectQueryAst selectQueryAst) {
return query;
}
+ /**
+ * Builds a KGRAM {@link Query} from a {@link DescribeQueryAst}.
+ *
+ * Reuses the shared query shell (compiled {@code WHERE} body, dataset, LIMIT/OFFSET) and
+ * {@code ORDER BY} like the other forms, then lowers {@code DESCRIBE} to the construct-like
+ * shape expected by the current KGRAM runtime. A described variable reuses its runtime node,
+ * a described IRI becomes a fresh constant node, and {@code DESCRIBE *} reuses the in-scope
+ * nodes of the body, matching {@code SELECT *}. Clauses that require dedicated aggregate or
+ * values handling are rejected explicitly.
+ */
+ public Query toNextQuery(DescribeQueryAst describeQueryAst) {
+ Objects.requireNonNull(describeQueryAst, "describeQueryAst");
+ rejectUnsupportedDescribeClauses(describeQueryAst);
+
+ Query query = createQuery(
+ describeQueryAst.whereClause(),
+ describeQueryAst.datasetClause(),
+ describeQueryAst.solutionModifier());
+ applyOrderBy(query, describeQueryAst.solutionModifier());
+ List describedNodes = describeNodes(query, describeQueryAst);
+ lowerDescribeToConstructQuery(query, describedNodes);
+ return query;
+ }
+
/**
* Converts a SPARQL {@code FILTER} clause by converting {@link FilterAst#operator()} the same way as
* {@link #toNextFilter(TermAst)}.
@@ -165,13 +190,11 @@ private static void rejectUnsupportedSelectClauses(SelectQueryAst selectQueryAst
if (!selectQueryAst.valuesClause().mappings().isEmpty()) {
throw new UnsupportedOperationException("VALUES is not supported yet when building a next Query");
}
-
ProjectionAst projection = selectQueryAst.projection();
if (!projection.expressionTerms().isEmpty() || !projection.expressionBoundVariables().isEmpty()) {
throw new UnsupportedOperationException(
"SELECT expressions are not supported yet when building a next Query");
}
-
SolutionModifierAst solutionModifier = selectQueryAst.solutionModifier();
if (solutionModifier.reduced()) {
throw new UnsupportedOperationException("REDUCED is not supported yet when building a next Query");
@@ -188,6 +211,21 @@ private static void rejectUnsupportedSelectClauses(SelectQueryAst selectQueryAst
// TODO(#387): REDUCED support should be aligned with the final next-pipeline query-form policy.
}
+ private static void rejectUnsupportedDescribeClauses(DescribeQueryAst describeQueryAst) {
+ if (!describeQueryAst.valuesClause().mappings().isEmpty()) {
+ throw new UnsupportedOperationException(
+ "Inline VALUES is not supported yet for DESCRIBE (values handling is a follow-up)");
+ }
+ SolutionModifierAst mod = describeQueryAst.solutionModifier();
+ if (mod.hasGroupBy() || mod.hasHaving() || mod.distinct() || mod.reduced()) {
+ throw new UnsupportedOperationException(
+ "Solution modifiers (GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for DESCRIBE");
+ }
+ // TODO(#390): inline VALUES needs a dedicated runtime mapping.
+ // TODO(#390): GROUP BY / HAVING would require aggregate-aware DESCRIBE semantics, not just field copying.
+ // TODO(#390): REDUCED support should be aligned with the final query-form policy for the next pipeline.
+ }
+
/**
* Creates the runtime {@link Query} shell shared by every query form handled here.
*
@@ -200,7 +238,7 @@ private Query createQuery(
DatasetClauseAst datasetClause,
SolutionModifierAst solutionModifier) {
Query query = Query.create(whereCompiler.compile(whereClause));
- // Collect visible nodes once so later clauses (projection, ORDER BY, GROUP BY)
+ // Collect visible nodes once so later clauses (projection, ORDER BY, DESCRIBE)
// can resolve variables against the compiled runtime body.
query.collect();
applyDataset(query, datasetClause);
@@ -243,11 +281,81 @@ private void applyProjection(Query query, ProjectionAst projection) {
selectExpressions.add(Exp.create(Type.NODE, node));
}
}
-
query.setSelectFun(selectExpressions);
query.setSelect(selectNodeList(selectExpressions));
}
+ /**
+ * Resolves the resources of a {@code DESCRIBE} against the compiled body.
+ *
+ * {@code DESCRIBE *} reuses the in-scope nodes of the body (like {@code SELECT *}).
+ * A described variable reuses its runtime node (so it is the one bound by the body) and
+ * fails fast when it is not visible; a described IRI becomes a fresh constant node.
+ */
+ private List describeNodes(Query query, DescribeQueryAst describeQueryAst) {
+ if (describeQueryAst.isDescribeAll()) {
+ return query.selectNodesFromPattern();
+ }
+ List nodes = new ArrayList<>();
+ for (TermAst term : describeQueryAst.described()) {
+ if (term instanceof VarAst(String name)) {
+ Node node = query.getExtNode(name);
+ if (node == null) {
+ throw new IllegalArgumentException(
+ "DESCRIBE variable ?" + name + " is not visible in the compiled query body");
+ }
+ nodes.add(node);
+ } else {
+ nodes.add(toNode(term));
+ }
+ }
+ return nodes;
+ }
+
+ /**
+ * Lowers {@code DESCRIBE} to the construct-like shape expected by the current
+ * KGRAM-next runtime.
+ *
+ * This keeps the current KGRAM contract, inherited from the historical pipeline:
+ * {@code DESCRIBE} is executed through the construct runtime path.
+ */
+ private void lowerDescribeToConstructQuery(Query query, List describedNodes) {
+ Exp constructTemplate = Exp.create(Type.BGP);
+ int syntheticIndex = 0;
+ for (Node describedNode : describedNodes) {
+ DescribePattern describePattern = describePattern(describedNode, syntheticIndex++);
+ // KGRAM-next currently represents DESCRIBE with outgoing and incoming construct triples.
+ constructTemplate.add(describePattern.outgoing().getEdge());
+ constructTemplate.add(describePattern.incoming().getEdge());
+ query.getBody().add(Exp.create(Type.OPTIONAL, Exp.create(Type.AND), describePattern.optionalBody()));
+ }
+ query.setConstruct(constructTemplate);
+ query.setConstruct(true);
+ query.setConstructNodes(constructTemplate.getNodes());
+ }
+
+ private DescribePattern describePattern(Node describedNode, int index) {
+ Node outgoingPredicate = createSyntheticDescribeNode("p", index, 0);
+ Node outgoingValue = createSyntheticDescribeNode("v", index, 0);
+ Node incomingPredicate = createSyntheticDescribeNode("p", index, 1);
+ Node incomingValue = createSyntheticDescribeNode("v", index, 1);
+
+ Exp outgoing = Exp.create(Type.EDGE, new AstBackedEdge(describedNode, outgoingPredicate, outgoingValue));
+ Exp incoming = Exp.create(Type.EDGE, new AstBackedEdge(incomingValue, incomingPredicate, describedNode));
+ Exp outgoingBgp = Exp.create(Type.BGP);
+ outgoingBgp.add(outgoing);
+ Exp incomingBgp = Exp.create(Type.BGP);
+ incomingBgp.add(incoming);
+ return new DescribePattern(outgoing, incoming, Exp.create(Type.UNION, outgoingBgp, incomingBgp));
+ }
+
+ private Node createSyntheticDescribeNode(String role, int describedIndex, int directionIndex) {
+ return new NodeImpl(Variable.create("__describe_" + role + "_" + describedIndex + "_" + directionIndex));
+ }
+
+ private record DescribePattern(Exp outgoing, Exp incoming, Exp optionalBody) {
+ }
+
/**
* Maps {@code ORDER BY} conditions that are already expressible in runtime KGRAM terms.
*
@@ -258,7 +366,6 @@ private void applyOrderBy(Query query, SolutionModifierAst solutionModifier) {
if (!solutionModifier.hasOrderBy()) {
return;
}
-
List orderByExpressions = new ArrayList<>();
int syntheticIndex = 0;
for (OrderConditionAst orderCondition : solutionModifier.orderBy()) {
@@ -282,7 +389,6 @@ private Exp toOrderByExpression(Query query, OrderConditionAst orderCondition, i
}
return Exp.create(Type.NODE, node);
}
-
Filter filter = SparqlAstToExpression.toNextFilter(expression);
Exp exp = Exp.create(Type.NODE, createSyntheticOrderNode(syntheticIndex));
exp.setFilter(filter);
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextDatatypeValueAdapter.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextDatatypeValueAdapter.java
index 954241456..7464ff198 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextDatatypeValueAdapter.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NextDatatypeValueAdapter.java
@@ -7,22 +7,12 @@
/**
* Adapts a runtime {@link IDatatype} to the Corese-next {@link DatatypeValue} API.
*/
-public final class NextDatatypeValueAdapter implements DatatypeValue {
-
- private final IDatatype delegate;
-
- public NextDatatypeValueAdapter(IDatatype delegate) {
- this.delegate = delegate;
- }
+public record NextDatatypeValueAdapter(IDatatype delegate) implements DatatypeValue {
public static DatatypeValue ofNullable(IDatatype dt) {
return dt == null ? null : new NextDatatypeValueAdapter(dt);
}
- public IDatatype delegate() {
- return delegate;
- }
-
@Override
public String getLabel() {
return delegate.getLabel();
@@ -90,8 +80,8 @@ public boolean isBlank() {
}
private static IDatatype unwrap(DatatypeValue other) throws CoreseDatatypeException {
- if (other instanceof NextDatatypeValueAdapter a) {
- return a.delegate;
+ if (other instanceof NextDatatypeValueAdapter(IDatatype delegate1)) {
+ return delegate1;
}
if (other instanceof IDatatype id) {
return id;
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlBuiltinFunctionNameResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlBuiltinFunctionNameResolver.java
index 02d4c0725..eb46b4cfd 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlBuiltinFunctionNameResolver.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlBuiltinFunctionNameResolver.java
@@ -18,10 +18,10 @@ private SparqlBuiltinFunctionNameResolver() {
* Resolves the function name from a term that must be an {@link IriAst}.
*/
public static String fromFunctionTerm(TermAst functionName) {
- if (!(functionName instanceof IriAst i)) {
+ if (!(functionName instanceof IriAst(String raw))) {
throw new IllegalArgumentException("Function name must be IriAst, got " + functionName);
}
- return localNameFromIriToken(i.raw());
+ return localNameFromIriToken(raw);
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/kgram/core/Query.java b/src/main/java/fr/inria/corese/core/next/query/kgram/core/Query.java
index f3127d5b6..0b0f34078 100644
--- a/src/main/java/fr/inria/corese/core/next/query/kgram/core/Query.java
+++ b/src/main/java/fr/inria/corese/core/next/query/kgram/core/Query.java
@@ -80,6 +80,7 @@ public static DQPFactory getFactory() {
//selectWithExp,
orderBy, groupBy;
List failure, pathFilter, funList;
+
List errors, info;
Exp having, construct, delete;
// gNode is a local graph node when subquery has no ?g in its select
@@ -822,6 +823,14 @@ public Exp getInsert() {
return construct;
}
+ public List getConstructNodes() {
+ return constructNodes;
+ }
+
+ public void setConstructNodes(List constructNodes) {
+ this.constructNodes = constructNodes;
+ }
+
public void setDelete(Exp c) {
delete = c;
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderAskTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderAskTest.java
index b4e3a3f1a..603320a88 100644
--- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderAskTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderAskTest.java
@@ -123,7 +123,7 @@ void rejectsValuesClause() {
}
@Test
- @DisplayName("toNextQuery(null) throws NullPointerException")
+ @DisplayName("toNextQuery((AskQueryAst) null) throws NullPointerException")
void rejectsNullAsk() {
assertThrows(NullPointerException.class, () -> builder.toNextQuery((AskQueryAst) null));
}
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
new file mode 100644
index 000000000..1ec39ad97
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderDescribeTest.java
@@ -0,0 +1,196 @@
+package fr.inria.corese.core.next.query.impl.sparql.bridge;
+
+import fr.inria.corese.core.next.query.impl.parser.SparqlParser;
+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 java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class CoreseAstQueryBuilderDescribeTest {
+
+ private final CoreseAstQueryBuilder builder = new CoreseAstQueryBuilder();
+
+ private static GroupGraphPatternAst whereBindingX() {
+ return new GroupGraphPatternAst(List.of(
+ new BgpAst(List.of(
+ new TriplePatternAst(new VarAst("x"), new VarAst("p"), new VarAst("o"))))));
+ }
+
+ @Test
+ @DisplayName("Parser -> DescribeQueryAst -> Query lowers DESCRIBE to a construct-like query")
+ void parserDescribeAstToQuery() {
+ SparqlParser parser = new SparqlParser();
+ DescribeQueryAst describe = (DescribeQueryAst) parser.parse(
+ "DESCRIBE ?x WHERE { ?x ?p ?o }");
+
+ Query query = builder.toNextQuery(describe);
+
+ assertLoweredDescribeConstruct(query, 1);
+ assertTrue(query.getBody().isAnd());
+ assertEquals(1, describeOptionalCount(query), "one DESCRIBE optional generated");
+ assertSame(query.getExtNode("x"), query.getConstruct().get(0).getEdge().getNode(0));
+ }
+
+ @Test
+ @DisplayName("DESCRIBE ?x WHERE { ... }: ?x resolves to the body node")
+ void describesVariableResolvedAgainstBody() {
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("x")), whereBindingX());
+
+ Query query = builder.toNextQuery(describe);
+
+ assertLoweredDescribeConstruct(query, 1);
+ assertTrue(query.getBody().isAnd(), "WHERE compiled into the body");
+ assertEquals(1, describeOptionalCount(query));
+ Node described = query.getConstruct().get(0).getEdge().getNode(0);
+ assertTrue(described.isVariable());
+ assertSame(query.getExtNode("x"), described, "described ?x is the runtime node bound by the body");
+ }
+
+ @Test
+ @DisplayName("DESCRIBE with no WHERE: IRI as a fresh constant node and lowered construct shape")
+ void describesFixedResourceWithoutWhere() {
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new IriAst("")), null);
+
+ Query query = builder.toNextQuery(describe);
+
+ assertLoweredDescribeConstruct(query, 1);
+ assertTrue(query.getBody().isAnd(), "body remains an AND");
+ assertEquals(1, describeOptionalCount(query));
+ assertFalse(query.getConstruct().get(0).getEdge().getNode(0).isVariable(), "described term is a fixed IRI");
+ }
+
+ @Test
+ @DisplayName("DESCRIBE * reuses the in-scope variables of the body (like SELECT *)")
+ void describeAllUsesInScopeVariables() {
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(), whereBindingX());
+
+ Query query = builder.toNextQuery(describe);
+
+ assertLoweredDescribeConstruct(query, 3);
+ assertEquals(3, describeOptionalCount(query));
+ List described = query.getConstruct().getNodes().stream()
+ .filter(node -> "x".equals(node.getLabel()) || "p".equals(node.getLabel()) || "o".equals(node.getLabel()))
+ .toList();
+ assertTrue(described.stream().allMatch(Node::isVariable));
+ assertTrue(described.stream().anyMatch(n -> "x".equals(n.getLabel())));
+ assertTrue(described.stream().anyMatch(n -> "p".equals(n.getLabel())), "variable predicate is in scope too");
+ assertTrue(described.stream().anyMatch(n -> "o".equals(n.getLabel())));
+ }
+
+ @Test
+ @DisplayName("DESCRIBE ?y where ?y is not visible in the body → IllegalArgumentException")
+ void rejectsDescribeVariableNotVisible() {
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("y")), whereBindingX());
+
+ assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(describe));
+ }
+
+ @Test
+ @DisplayName("FROM / FROM NAMED are applied to the query dataset")
+ void appliesDatasetClause() {
+ DatasetClauseAst dataset = new DatasetClauseAst(
+ Set.of(new IriAst("http://example.org/g")),
+ Set.of(new IriAst("http://example.org/n")));
+ DescribeQueryAst describe = new DescribeQueryAst(
+ dataset, List.of(new VarAst("x")), whereBindingX());
+
+ Query query = builder.toNextQuery(describe);
+
+ assertEquals(1, query.getFrom().size(), "FROM applied");
+ assertEquals(1, query.getNamed().size(), "FROM NAMED applied");
+ }
+
+ @Test
+ @DisplayName("LIMIT / OFFSET are applied to the query")
+ void appliesLimitAndOffset() {
+ SolutionModifierAst mod = SolutionModifierAst.withoutGroupBy(
+ false, false, List.of(), 5L, 2L);
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("x")), whereBindingX(), mod);
+
+ Query query = builder.toNextQuery(describe);
+
+ assertEquals(5, query.getLimit(), "LIMIT applied");
+ assertEquals(2, query.getOffset(), "OFFSET applied");
+ }
+
+ @Test
+ @DisplayName("ORDER BY ?x is applied to the query")
+ void appliesOrderBy() {
+ SolutionModifierAst mod = SolutionModifierAst.withoutGroupBy(
+ false, false,
+ List.of(new OrderConditionAst(ASTConstants.OrderDirection.ASC, new VarAst("x"))),
+ null, null);
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("x")), whereBindingX(), mod);
+
+ Query query = builder.toNextQuery(describe);
+
+ assertFalse(query.getOrderBy().isEmpty(), "ORDER BY applied");
+ }
+
+ @Test
+ @DisplayName("Inline VALUES is not supported yet → UnsupportedOperationException")
+ void rejectsValuesClause() {
+ ValuesAst values = new ValuesAst(List.of(
+ new ValueMappingAst(Map.of(new VarAst("x"), new IriAst("http://example.org/v")))));
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("x")), whereBindingX(), null, null, values);
+
+ assertThrows(UnsupportedOperationException.class, () -> builder.toNextQuery(describe));
+ }
+
+ @Test
+ @DisplayName("Unsupported solution modifier (e.g. DISTINCT) → UnsupportedOperationException")
+ void rejectsUnsupportedModifier() {
+ SolutionModifierAst mod = SolutionModifierAst.withoutGroupBy(
+ true, false, List.of(), null, null);
+ DescribeQueryAst describe = new DescribeQueryAst(
+ DatasetClauseAst.none(), List.of(new VarAst("x")), whereBindingX(), mod);
+
+ assertThrows(UnsupportedOperationException.class, () -> builder.toNextQuery(describe));
+ }
+
+ @Test
+ @DisplayName("toNextQuery((DescribeQueryAst) null) throws NullPointerException")
+ void rejectsNull() {
+ assertThrows(NullPointerException.class, () -> builder.toNextQuery((DescribeQueryAst) null));
+ }
+
+ private static void assertLoweredDescribeConstruct(Query query, int describedNodeCount) {
+ assertTrue(query.isConstruct(), "DESCRIBE is lowered to the construct runtime path");
+ assertFalse(query.isSelect(), "a lowered DESCRIBE must not report itself as SELECT");
+ assertNotNull(query.getConstruct(), "construct template created");
+ assertEquals(2 * describedNodeCount, query.getConstruct().size(),
+ "construct template has outgoing and incoming triple patterns per described node");
+ assertNotNull(query.getConstructNodes(), "construct nodes collected");
+ }
+
+ /**
+ * Counts the DESCRIBE-generated {@code OPTIONAL { outgoing UNION incoming }} patterns directly
+ * under the body — independently of how the parser shaped the original WHERE.
+ */
+ private static long describeOptionalCount(Query query) {
+ Exp body = query.getBody();
+ long count = 0;
+ for (int i = 0; i < body.size(); i++) {
+ Exp child = body.get(i);
+ if (child.isOptional() && child.size() > 1 && child.get(1).isUnion()) {
+ count++;
+ }
+ }
+ return count;
+ }
+}