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 716054002..3f5db08b8 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 @@ -1,55 +1,211 @@ package fr.inria.corese.core.next.query.impl.sparql.bridge; import fr.inria.corese.core.next.query.impl.sparql.ast.AskQueryAst; +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.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.OrderConditionAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ProjectionAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; import fr.inria.corese.core.next.query.impl.sparql.ast.SolutionModifierAst; import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; +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 fr.inria.corese.core.sparql.triple.parser.Variable; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; /** * 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.

*/ public final class CoreseAstQueryBuilder { - private final WhereCompiler whereCompiler = new WhereCompiler(); + private final WhereCompiler whereCompiler; + + /** + * Creates a bridge backed by the default {@link WhereCompiler}. + */ + public CoreseAstQueryBuilder() { + this(new WhereCompiler()); + } + + /** + * Creates a bridge with an explicit {@link WhereCompiler}. + * + *

This constructor stays package-visible so tests and same-package bridge code + * can inject a specialized compiler without widening the public API surface.

+ */ + CoreseAstQueryBuilder(WhereCompiler whereCompiler) { + this.whereCompiler = Objects.requireNonNull(whereCompiler, "whereCompiler"); + } /** * Builds a KGRAM {@link Query} from a SPARQL {@code ASK} query AST. + * + *

The bridge maps the query body and the modifiers that already have a direct + * runtime representation ({@code FROM}, {@code FROM NAMED}, {@code ORDER BY}, + * {@code LIMIT}, {@code OFFSET}). Clauses that require dedicated aggregate or + * values handling are rejected explicitly.

*/ public Query toNextQuery(AskQueryAst askQueryAst) { Objects.requireNonNull(askQueryAst, "askQueryAst"); - rejectUnsupportedClauses(askQueryAst); + rejectUnsupportedAskClauses(askQueryAst); - Query query = Query.create(whereCompiler.compile(askQueryAst.whereClause())); - applyDataset(query, askQueryAst.datasetClause()); - applySolutionModifier(query, askQueryAst.solutionModifier()); + Query query = createQuery( + askQueryAst.whereClause(), + askQueryAst.datasetClause(), + askQueryAst.solutionModifier()); + applyOrderBy(query, askQueryAst.solutionModifier()); query.setAsk(true); return query; } - private static void rejectUnsupportedClauses(AskQueryAst askQueryAst) { + /** + * Builds a KGRAM {@link Query} from a {@link SelectQueryAst}. + * + *

The bridge maps the compiled {@code WHERE} body, plain projection, + * dataset clauses, and solution modifiers that already have a direct runtime + * representation ({@code DISTINCT}, {@code ORDER BY}, {@code LIMIT}, {@code OFFSET}). + * Clauses that require dedicated aggregate, alias, or values handling are rejected + * explicitly.

+ */ + public Query toNextQuery(SelectQueryAst selectQueryAst) { + Objects.requireNonNull(selectQueryAst, "selectQueryAst"); + rejectUnsupportedSelectClauses(selectQueryAst); + + Query query = createQuery( + selectQueryAst.whereClause(), + selectQueryAst.datasetClause(), + selectQueryAst.solutionModifier()); + applyProjection(query, selectQueryAst.projection()); + query.setDistinct(selectQueryAst.solutionModifier().distinct()); + applyOrderBy(query, selectQueryAst.solutionModifier()); + return query; + } + + /** + * Converts a SPARQL {@code FILTER} clause by converting {@link FilterAst#operator()} the same way as + * {@link #toNextFilter(TermAst)}. + */ + public Filter toNextFilter(FilterAst filterClause) { + Objects.requireNonNull(filterClause, "filterClause"); + return toNextFilter(filterClause.operator()); + } + + /** + * Converts a filter expression carried as {@link TermAst}: must be a {@link ConstraintAst}. + */ + public Filter toNextFilter(TermAst filterExpression) { + Objects.requireNonNull(filterExpression, "filterExpression"); + if (!(filterExpression instanceof ConstraintAst constraint)) { + throw new IllegalArgumentException( + "FILTER expects a ConstraintAst, got: " + filterExpression.getClass().getName()); + } + return toNextFilter(constraint); + } + + /** + * Converts a constraint tree (boolean filter expression) into a KGRAM {@link Filter}. + */ + public Filter toNextFilter(ConstraintAst filterExpression) { + Objects.requireNonNull(filterExpression, "filterExpression"); + return SparqlAstToExpression.toNextFilter(filterExpression); + } + + /** + * Converts a query term used as subject, predicate, object, or variable reference + * into a runtime {@link Node}. + * + *

This helper is package-visible because both the query builder and the + * {@link WhereCompiler} need a single shared term-to-node conversion rule.

+ */ + static Node toNode(TermAst term) { + Expression expression = SparqlAstToExpression.convert(term); + if (expression instanceof Atom atom) { + return new NodeImpl(atom); + } + throw new IllegalArgumentException( + "A query term must be a variable, IRI or literal, got: " + + term.getClass().getSimpleName()); + } + + private static void rejectUnsupportedAskClauses(AskQueryAst askQueryAst) { if (!askQueryAst.valuesClause().mappings().isEmpty()) { throw new UnsupportedOperationException( "Inline VALUES is not supported yet for ASK (values handling is a follow-up)"); } SolutionModifierAst mod = askQueryAst.solutionModifier(); - if (mod.hasOrderBy() || mod.hasGroupBy() || mod.hasHaving() - || mod.distinct() || mod.reduced()) { + if (mod.hasGroupBy() || mod.hasHaving() || mod.distinct() || mod.reduced()) { throw new UnsupportedOperationException( - "Solution modifiers (ORDER BY, GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for ASK"); + "Solution modifiers (GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for ASK"); } + // TODO(#388): inline VALUES needs a dedicated runtime mapping. + // TODO(#388): GROUP BY / HAVING would require aggregate-aware ASK semantics, not just field copying. + // TODO(#388): REDUCED support should be aligned with the final query-form policy for the next pipeline. + } + + 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"); + } + if (solutionModifier.hasGroupBy()) { + throw new UnsupportedOperationException("GROUP BY is not supported yet when building a next Query"); + } + if (solutionModifier.hasHaving()) { + throw new UnsupportedOperationException("HAVING is not supported yet when building a next Query"); + } + // TODO(#387): inline VALUES needs a dedicated runtime mapping. + // TODO(#387): SELECT expressions need a clear runtime story for aliases and reuse in later clauses. + // TODO(#387): GROUP BY / HAVING require aggregate semantics, not only AST field propagation. + // TODO(#387): REDUCED support should be aligned with the final next-pipeline query-form policy. + } + + /** + * Creates the runtime {@link Query} shell shared by every query form handled here. + * + *

The compiled {@code WHERE} body comes first, then the builder copies dataset + * and limit/offset information, and finally collects visible nodes so later clauses + * can resolve variables against the runtime body.

+ */ + private Query createQuery( + GroupGraphPatternAst whereClause, + DatasetClauseAst datasetClause, + SolutionModifierAst solutionModifier) { + Query query = Query.create(whereCompiler.compile(whereClause)); + // Collect visible nodes once so later clauses (projection, ORDER BY, GROUP BY) + // can resolve variables against the compiled runtime body. + query.collect(); + applyDataset(query, datasetClause); + applyLimitOffset(query, solutionModifier); + return query; } private void applyDataset(Query query, DatasetClauseAst datasetClause) { @@ -65,52 +221,100 @@ private List toNodeList(Iterable iris) { return nodes; } - private void applySolutionModifier(Query query, SolutionModifierAst solutionModifier) { - if (solutionModifier.hasLimit()) { - query.setLimit(Math.toIntExact(solutionModifier.limit())); + /** + * Maps the {@code SELECT} projection onto the runtime query. + * + *

{@code SELECT *} reuses the visible nodes collected from the compiled + * query body. An explicit projection reuses these same runtime nodes and fails + * fast when a projected variable is not visible in the body.

+ */ + private void applyProjection(Query query, ProjectionAst projection) { + List selectExpressions; + if (projection.selectAll()) { + selectExpressions = toNodeExpressions(query.selectNodesFromPattern()); + } else { + selectExpressions = new ArrayList<>(); + for (VarAst variable : projection.variables()) { + Node node = query.getExtNode(variable.name()); + if (node == null) { + throw new IllegalArgumentException( + "Projected variable ?" + variable.name() + " is not visible in the compiled query body"); + } + selectExpressions.add(Exp.create(Type.NODE, node)); + } } - if (solutionModifier.hasOffset()) { - query.setOffset(Math.toIntExact(solutionModifier.offset())); + + query.setSelectFun(selectExpressions); + query.setSelect(selectNodeList(selectExpressions)); + } + + /** + * Maps {@code ORDER BY} conditions that are already expressible in runtime KGRAM terms. + * + *

Variables reuse already-visible query nodes. Other expressions are wrapped as runtime + * filters and attached to synthetic internal nodes, just like the historical pipeline does.

+ */ + private void applyOrderBy(Query query, SolutionModifierAst solutionModifier) { + if (!solutionModifier.hasOrderBy()) { + return; + } + + List orderByExpressions = new ArrayList<>(); + int syntheticIndex = 0; + for (OrderConditionAst orderCondition : solutionModifier.orderBy()) { + Exp orderExpression = toOrderByExpression(query, orderCondition, syntheticIndex++); + orderExpression.status(orderCondition.orderDirection() == ASTConstants.OrderDirection.DESC); + orderByExpressions.add(orderExpression); } + query.setOrderBy(orderByExpressions); } - static Node toNode(TermAst term) { - Expression expression = SparqlAstToExpression.convert(term); - if (expression instanceof Atom atom) { - return new NodeImpl(atom); + private Exp toOrderByExpression(Query query, OrderConditionAst orderCondition, int syntheticIndex) { + TermAst expression = orderCondition.expression(); + if (expression instanceof VarAst(String name)) { + Exp selectExpression = query.getSelectExp(name); + Node node = selectExpression != null + ? selectExpression.getNode() + : query.getProperAndSubSelectNode(name); + if (node == null) { + throw new IllegalArgumentException( + "ORDER BY variable ?" + name + " is not visible in the compiled query"); + } + return Exp.create(Type.NODE, node); } - throw new IllegalArgumentException( - "A query term must be a variable, IRI or literal, got: " - + term.getClass().getSimpleName()); + + Filter filter = SparqlAstToExpression.toNextFilter(expression); + Exp exp = Exp.create(Type.NODE, createSyntheticOrderNode(syntheticIndex)); + exp.setFilter(filter); + return exp; } - /** - * Converts a SPARQL {@code FILTER} clause by converting {@link FilterAst#operator()} the same way as - * {@link #toNextFilter(TermAst)}. - */ - public Filter toNextFilter(FilterAst filterClause) { - Objects.requireNonNull(filterClause, "filterClause"); - return toNextFilter(filterClause.operator()); + private Node createSyntheticOrderNode(int syntheticIndex) { + return new NodeImpl(Variable.create("__order_by_" + syntheticIndex)); } - /** - * Converts a filter expression carried as {@link TermAst}: must be a {@link ConstraintAst}. - */ - public Filter toNextFilter(TermAst filterExpression) { - Objects.requireNonNull(filterExpression, "filterExpression"); - if (!(filterExpression instanceof ConstraintAst constraint)) { - throw new IllegalArgumentException( - "FILTER expects a ConstraintAst, got: " + filterExpression.getClass().getName()); + private List toNodeExpressions(List nodes) { + List expressions = new ArrayList<>(); + for (Node node : nodes) { + expressions.add(Exp.create(Type.NODE, node)); } - return toNextFilter(constraint); + return expressions; } - /** - * Converts a constraint tree (boolean filter expression) into a KGRAM {@link Filter}. - */ - public Filter toNextFilter(ConstraintAst filterExpression) { - Objects.requireNonNull(filterExpression, "filterExpression"); - return SparqlAstToExpression.toNextFilter(filterExpression); + private List selectNodeList(List selectExpressions) { + LinkedHashSet selectNodes = new LinkedHashSet<>(); + for (Exp selectExpression : selectExpressions) { + selectNodes.add(selectExpression.getNode()); + } + return new ArrayList<>(selectNodes); } + private void applyLimitOffset(Query query, SolutionModifierAst solutionModifier) { + if (solutionModifier.hasLimit()) { + query.setLimit(Math.toIntExact(solutionModifier.limit())); + } + if (solutionModifier.hasOffset()) { + query.setOffset(Math.toIntExact(solutionModifier.offset())); + } + } } 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 index 0dcf6b24e..7bb436a7a 100644 --- 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 @@ -1,6 +1,15 @@ 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.BgpAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.BindAst; +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.MinusAst; +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.ServiceAst; +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.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; @@ -17,15 +26,27 @@ */ public final class WhereCompiler { + /** + * Compiles a full SPARQL {@code WHERE} clause into the runtime body carried by a + * KGRAM {@link Query}. + * + *

This is the main entry point of the compiler. It expects the root + * {@link GroupGraphPatternAst} produced by the parser for a complete + * {@code WHERE { ... }} block.

+ */ public Exp compile(GroupGraphPatternAst where) { Objects.requireNonNull(where, "where"); return compileGroup(where); } /** - * Dispatch a single {@link PatternAst} element to its KGRAM {@link Exp}. + * Compiles a single {@link PatternAst} node. + * + *

This package-visible dispatcher is used inside the bridge while recursively + * traversing a {@code WHERE} tree, and by same-package tests that exercise one + * pattern kind in isolation.

*/ - public Exp compile(PatternAst pattern) { + Exp compile(PatternAst pattern) { Objects.requireNonNull(pattern, "pattern"); return switch (pattern) { case BgpAst bgp -> compileBgp(bgp); @@ -41,7 +62,6 @@ public Exp compile(PatternAst pattern) { }; } - /** * {@code { e1 e2 ... en }} becomes an {@code AND} of the compiled elements. */ @@ -69,7 +89,6 @@ case MinusAst(GroupGraphPatternAst pattern) -> { return body; } - private Exp compileBgp(BgpAst bgp) { Exp pattern = Exp.create(Type.BGP); for (TriplePatternAst triple : bgp.triples()) { @@ -85,20 +104,17 @@ private Edge toEdge(TriplePatternAst triple) { return new AstBackedEdge(subject, predicate, object); } - 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 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 3e99eec9f..b4e3a3f1a 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 @@ -2,7 +2,18 @@ import fr.inria.corese.core.next.query.impl.parser.AbstractSparqlParserFeatureTest; 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.impl.sparql.ast.AskQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.BgpAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.DatasetClauseAst; +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.SolutionModifierAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ValueMappingAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ValuesAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; import fr.inria.corese.core.next.query.impl.sparql.ast.constraint.GreaterThanAst; import fr.inria.corese.core.next.query.kgram.api.core.Node; import fr.inria.corese.core.next.query.kgram.core.Query; @@ -13,7 +24,12 @@ import java.util.Map; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +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; +import static org.junit.jupiter.api.Assertions.assertTrue; class CoreseAstQueryBuilderAskTest extends AbstractSparqlParserFeatureTest { @@ -109,7 +125,7 @@ void rejectsValuesClause() { @Test @DisplayName("toNextQuery(null) throws NullPointerException") void rejectsNullAsk() { - assertThrows(NullPointerException.class, () -> builder.toNextQuery(null)); + assertThrows(NullPointerException.class, () -> builder.toNextQuery((AskQueryAst) null)); } @Test @@ -139,16 +155,16 @@ void appliesLimitAndOffset() { } @Test - @DisplayName("ORDER BY stays out of scope for this first ASK increment") - void rejectsOrderBy() { + @DisplayName("ORDER BY is mapped onto the runtime ASK query when it is already directly supported") + void appliesOrderBy() { AskQueryAst ask = assertInstanceOf(AskQueryAst.class, newParserDefault().parse("ASK WHERE { ?s ?p ?o . } ORDER BY ?s")); - UnsupportedOperationException error = - assertThrows(UnsupportedOperationException.class, () -> builder.toNextQuery(ask)); + Query query = builder.toNextQuery(ask); - assertEquals("Solution modifiers (ORDER BY, GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for ASK", - error.getMessage()); + assertEquals(1, query.getOrderBy().size()); + assertEquals("s", query.getOrderBy().getFirst().getNode().getLabel()); + assertFalse(query.getOrderBy().getFirst().status()); } private static List labels(List nodes) { 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 new file mode 100644 index 000000000..9b26f75ca --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilderTest.java @@ -0,0 +1,203 @@ +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.BgpAst; +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.OrderConditionAst; +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; +import fr.inria.corese.core.next.query.impl.sparql.ast.SolutionModifierAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ASTConstants; +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.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CoreseAstQueryBuilderTest extends AbstractSparqlParserFeatureTest { + + private final CoreseAstQueryBuilder builder = new CoreseAstQueryBuilder(); + + @Test + @DisplayName("SELECT * WHERE { ?s ?p ?o . } builds a next Query") + void buildsMinimalSelectStarQuery() { + SelectQueryAst select = new SelectQueryAst(group(singleTriple())); + + Query query = builder.toNextQuery(select); + + assertTrue(query.getBody().isAnd(), "the query body should be an AND group"); + assertEquals(1, query.getBody().size(), "the WHERE should contain one compiled element"); + + Exp bgp = query.getBody().get(0); + assertTrue(bgp.isBGP(), "the compiled WHERE should contain one BGP"); + assertEquals(List.of("s", "p", "o"), labels(query.getSelect()), + "SELECT * should project the visible WHERE variables"); + } + + @Test + @DisplayName("Parser -> SelectQueryAst -> Query keeps the SELECT * path executable") + void buildsQueryFromParsedSelectStar() { + QueryAst ast = newParserDefault().parse("SELECT * WHERE { ?s ?p ?o . }"); + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, ast); + + Query query = builder.toNextQuery(select); + + assertEquals(List.of("s", "p", "o"), labels(query.getSelect())); + assertTrue(query.getBody().get(0).isBGP()); + } + + @Test + @DisplayName("Explicit projection reuses the compiled query nodes") + void buildsExplicitProjection() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.of(List.of(new VarAst("o"), new VarAst("s"))), + null, + group(singleTriple())); + + Query query = builder.toNextQuery(select); + + assertEquals(List.of("o", "s"), labels(query.getSelect())); + } + + @Test + @DisplayName("DISTINCT, LIMIT and OFFSET are copied onto the next Query") + void appliesSimpleSolutionModifiers() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + null, + group(singleTriple()), + SolutionModifierAst.withoutGroupBy(true, false, List.of(), 10L, 3L)); + + Query query = builder.toNextQuery(select); + + assertTrue(query.isDistinct()); + assertEquals(10, query.getLimit()); + assertEquals(3, query.getOffset()); + } + + @Test + @DisplayName("ORDER BY ?var is mapped onto runtime order expressions") + void appliesVariableOrderBy() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + null, + group(singleTriple()), + SolutionModifierAst.withoutGroupBy(false, false, + List.of(new OrderConditionAst(ASTConstants.OrderDirection.ASC, new VarAst("s"))), + null, + null)); + + Query query = builder.toNextQuery(select); + + assertEquals(1, query.getOrderBy().size()); + assertEquals("s", query.getOrderBy().getFirst().getNode().getLabel()); + assertTrue(!query.getOrderBy().getFirst().status(), "ASC should keep the default ascending status"); + } + + @Test + @DisplayName("ORDER BY DESC(?var) keeps the descending flag on the runtime expression") + void appliesDescendingOrderBy() { + QueryAst ast = newParserDefault().parse("SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)"); + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, ast); + + Query query = builder.toNextQuery(select); + + assertEquals(1, query.getOrderBy().size()); + assertEquals("o", query.getOrderBy().getFirst().getNode().getLabel()); + assertTrue(query.getOrderBy().getFirst().status(), "DESC should be preserved on the runtime expression"); + } + + @Test + @DisplayName("ORDER BY expressions are wrapped as runtime filters") + void appliesExpressionOrderBy() { + QueryAst ast = newParserDefault().parse("SELECT * WHERE { ?s ?p ?o . } ORDER BY STR(?o)"); + SelectQueryAst select = assertInstanceOf(SelectQueryAst.class, ast); + + Query query = builder.toNextQuery(select); + + assertEquals(1, query.getOrderBy().size()); + assertNotNull(query.getOrderBy().getFirst().getFilter(), + "non-variable ORDER BY expressions should be carried by a runtime filter"); + } + + @Test + @DisplayName("FROM and FROM NAMED are copied onto the next Query dataset") + void appliesDatasetClauses() { + DatasetClauseAst dataset = new DatasetClauseAst( + Set.of(new IriAst("http://example.org/default")), + Set.of(new IriAst("http://example.org/named"))); + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + dataset, + group(singleTriple())); + + Query query = builder.toNextQuery(select); + + assertEquals(List.of("http://example.org/default"), labels(query.getFrom())); + assertEquals(List.of("http://example.org/named"), labels(query.getNamed())); + } + + @Test + @DisplayName("Projecting a variable outside the WHERE stays an explicit error") + void rejectsProjectionOutsideWhereScope() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.of(List.of(new VarAst("missing"))), + null, + group(singleTriple())); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(select)); + + assertEquals("Projected variable ?missing is not visible in the compiled query body", error.getMessage()); + } + + @Test + @DisplayName("toNextQuery(null) throws NullPointerException for SELECT as well") + void rejectsNullSelect() { + assertThrows(NullPointerException.class, () -> builder.toNextQuery((SelectQueryAst) null)); + } + + @Test + @DisplayName("ORDER BY on an unknown variable stays an explicit error") + void rejectsUnknownOrderByVariable() { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + null, + group(singleTriple()), + SolutionModifierAst.withoutGroupBy(false, false, + List.of(new OrderConditionAst(ASTConstants.OrderDirection.ASC, new VarAst("missing"))), + null, + null)); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> builder.toNextQuery(select)); + + assertEquals("ORDER BY variable ?missing is not visible in the compiled query", error.getMessage()); + } + + private static TriplePatternAst singleTriple() { + return new TriplePatternAst(new VarAst("s"), new VarAst("p"), new VarAst("o")); + } + + private static GroupGraphPatternAst group(TriplePatternAst triple) { + return new GroupGraphPatternAst(List.of(new BgpAst(List.of(triple)))); + } + + private static List labels(List nodes) { + return nodes.stream().map(Node::getLabel).toList(); + } +}