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; + } +}