diff --git a/build.gradle.kts b/build.gradle.kts index 517434985..b2da5521c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.api.tasks.compile.JavaCompile import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -54,7 +55,7 @@ javacc { sourceSets { main { java { - setSrcDirs(listOf("src/main/java", javaccGeneratedDir, antlrPackageDir)) + setSrcDirs(listOf("src/main/java", javaccGeneratedDir, antlrGeneratedDir)) } } } @@ -306,11 +307,15 @@ tasks.named("compileJava") { dependsOn("generateGrammarSource", "javaccSparqlCorese") } +tasks.named("compileJava") { + source(antlrPackageDir) +} + // Ensure sources JAR includes generated sources and depends on code generation tasks.named("sourcesJar") { dependsOn("generateGrammarSource", "javaccSparqlCorese") from(javaccGeneratedDir) - from(antlrPackageDir) + from(antlrGeneratedDir) includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.EXCLUDE } diff --git a/src/main/java/fr/inria/corese/core/next/query/api/exception/UnsupportedQueryFeatureException.java b/src/main/java/fr/inria/corese/core/next/query/api/exception/UnsupportedQueryFeatureException.java new file mode 100644 index 000000000..2447f2a49 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/api/exception/UnsupportedQueryFeatureException.java @@ -0,0 +1,16 @@ +package fr.inria.corese.core.next.query.api.exception; + +/** + * Thrown when a syntactically valid query uses a feature not implemented by the current next pipeline. + */ +@SuppressWarnings("java:S110") +public class UnsupportedQueryFeatureException extends QueryEvaluationException { + + public UnsupportedQueryFeatureException(String message) { + super(message); + } + + public UnsupportedQueryFeatureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java index 67f0c8e3f..06e5f1bc9 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlAstBuilder.java @@ -267,6 +267,10 @@ public void enterBgp() { bgpStack.push(new ArrayList<>()); } + public boolean hasCurrentBgp() { + return !bgpStack.isEmpty(); + } + /** * Exit a TriplesBlock -> finalize into BgpAst and attach it to the current group. * Empty triples blocks produce no pattern. @@ -1180,9 +1184,9 @@ public TermAst termFromAdditive(SparqlParser.AdditiveExpressionContext ctx) { case SparqlParser.MultiplicativeExpressionContext multiplicativeExpressionContext -> termFromMultiplicative(multiplicativeExpressionContext); case SparqlParser.NumericLiteralPositiveContext numericLiteralPositiveContext -> - (ExprAst) termFromNumericLiteralPositive(numericLiteralPositiveContext); + termFromNumericLiteralPositive(numericLiteralPositiveContext); case SparqlParser.NumericLiteralNegativeContext numericLiteralNegativeContext -> - (ExprAst) termFromNumericLiteralNegative(numericLiteralNegativeContext); + termFromNumericLiteralNegative(numericLiteralNegativeContext); case null, default -> throw new QueryEvaluationException( "Unexpected left hand termFromExpression in additive termFromExpression " diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java index 374db8c86..ced18cbf0 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlListener.java @@ -8,9 +8,8 @@ import java.util.List; /** - * SPARQL listener multiplexer: forwards to delegates only the events needed for - * triple patterns (?s ?p ?o) and BGPs (GroupGraphPattern, TriplesBlock, TriplesSameSubject). - * Additional rules can be added later if needed. + * SPARQL listener multiplexer: forwards the grammar events supported by the + * next AST builders to their feature-specific delegates. */ public final class SparqlListener extends SparqlParserBaseListener { @@ -79,6 +78,16 @@ public void exitLoad(SparqlParser.LoadContext ctx) { for (var d : delegates) d.exitLoad(ctx); } + @Override + public void exitInsertData(SparqlParser.InsertDataContext ctx) { + for (var d : delegates) d.exitInsertData(ctx); + } + + @Override + public void exitDeleteData(SparqlParser.DeleteDataContext ctx) { + for (var d : delegates) d.exitDeleteData(ctx); + } + @Override public void enterClear(SparqlParser.ClearContext ctx) { for (var d : delegates) d.enterClear(ctx); diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlQueryAnalyzer.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlQueryAnalyzer.java index 12f4fcee2..63085e6dc 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlQueryAnalyzer.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlQueryAnalyzer.java @@ -142,6 +142,8 @@ private QueryAst buildAst(ParseTree tree, SparqlParserOptions options) { SparqlListener updateListener = new SparqlListener(List.of( new ClearRequestFeature(updateBuilder), new CreateRequestFeature(updateBuilder), + new DeleteDataRequestFeature(updateBuilder), + new InsertDataRequestFeature(updateBuilder), new LoadRequestFeature(updateBuilder), new BgpFeature(updateBuilder), new BindFeature(updateBuilder), diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlUpdateAstBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlUpdateAstBuilder.java index ac4270542..79e0768ed 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlUpdateAstBuilder.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/SparqlUpdateAstBuilder.java @@ -2,6 +2,7 @@ import fr.inria.corese.core.next.impl.parser.antlr.SparqlParser; import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; import fr.inria.corese.core.next.query.impl.sparql.ast.*; import java.util.ArrayList; @@ -67,6 +68,62 @@ public CreateRequestAst createToAst(SparqlParser.CreateContext ctx) { throw new QueryEvaluationException("No target graph reference found in CREATE query"); } + public InsertDataRequestAst insertDataToAst(SparqlParser.InsertDataContext ctx) { + return new InsertDataRequestAst(triplesFromQuadData(ctx.quadData())); + } + + public DeleteDataRequestAst deleteDataToAst(SparqlParser.DeleteDataContext ctx) { + return new DeleteDataRequestAst(triplesFromQuadData(ctx.quadData())); + } + + private List triplesFromQuadData(SparqlParser.QuadDataContext ctx) { + if (ctx == null || ctx.quads() == null) { + return List.of(); + } + return triplesFromQuads(ctx.quads()); + } + + private List triplesFromQuads(SparqlParser.QuadsContext ctx) { + if (ctx.quadsNotTriples() != null && !ctx.quadsNotTriples().isEmpty()) { + throw new UnsupportedQueryFeatureException( + "GRAPH blocks in INSERT DATA / DELETE DATA are not supported yet by the next pipeline"); + } + + List triples = new ArrayList<>(); + if (ctx.triplesTemplate() != null) { + for (SparqlParser.TriplesTemplateContext template : ctx.triplesTemplate()) { + collectTriplesTemplate(template, triples); + } + } + return List.copyOf(triples); + } + + private void collectTriplesTemplate(SparqlParser.TriplesTemplateContext ctx, List triples) { + if (ctx == null) { + return; + } + if (ctx.triplesSameSubject() != null) { + collectTriplesSameSubject(ctx.triplesSameSubject(), triples); + } + if (ctx.triplesTemplate() != null) { + collectTriplesTemplate(ctx.triplesTemplate(), triples); + } + } + + private void collectTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx, List triples) { + if (ctx.varOrTerm() == null || ctx.propertyListNotEmpty() == null) { + return; + } + TermAst subject = termFromVarOrTerm(ctx.varOrTerm()); + var propertyList = ctx.propertyListNotEmpty(); + for (int verbIndex = 0; verbIndex < propertyList.verb().size(); verbIndex++) { + TermAst predicate = termFromVerb(propertyList.verb(verbIndex)); + for (TermAst object : termListFromObjectList(propertyList.objectList(verbIndex))) { + triples.add(new TriplePatternAst(subject, predicate, object)); + } + } + } + public void addRequest(UpdateRequestUnitAst ast) { this.updateRequestAst.add(ast); } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BgpFeature.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BgpFeature.java index 1d7a69321..ae83274e1 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BgpFeature.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/BgpFeature.java @@ -67,6 +67,9 @@ public void exitTriplesSameSubject(SparqlParser.TriplesSameSubjectContext ctx) { if (ctx.getParent() instanceof SparqlParser.ConstructTriplesContext) { return; } + if (!builder().hasCurrentBgp()) { + return; + } if (ctx.varOrTerm() == null || ctx.propertyListNotEmpty() == null) { return; } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/DeleteDataRequestFeature.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/DeleteDataRequestFeature.java new file mode 100644 index 000000000..54cabc6c0 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/DeleteDataRequestFeature.java @@ -0,0 +1,19 @@ +package fr.inria.corese.core.next.query.impl.parser.listener; + +import fr.inria.corese.core.next.impl.parser.antlr.SparqlParser; +import fr.inria.corese.core.next.query.impl.parser.SparqlUpdateAstBuilder; + +/** + * AST feature listener for the minimal {@code DELETE DATA} update operation. + */ +public class DeleteDataRequestFeature extends AbstractSparqlRequestFeature { + + public DeleteDataRequestFeature(SparqlUpdateAstBuilder builder) { + super(builder); + } + + @Override + public void exitDeleteData(SparqlParser.DeleteDataContext ctx) { + updateBuilder().addRequest(updateBuilder().deleteDataToAst(ctx)); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/InsertDataRequestFeature.java b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/InsertDataRequestFeature.java new file mode 100644 index 000000000..2185de774 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/parser/listener/InsertDataRequestFeature.java @@ -0,0 +1,19 @@ +package fr.inria.corese.core.next.query.impl.parser.listener; + +import fr.inria.corese.core.next.impl.parser.antlr.SparqlParser; +import fr.inria.corese.core.next.query.impl.parser.SparqlUpdateAstBuilder; + +/** + * AST feature listener for the minimal {@code INSERT DATA} update operation. + */ +public class InsertDataRequestFeature extends AbstractSparqlRequestFeature { + + public InsertDataRequestFeature(SparqlUpdateAstBuilder builder) { + super(builder); + } + + @Override + public void exitInsertData(SparqlParser.InsertDataContext ctx) { + updateBuilder().addRequest(updateBuilder().insertDataToAst(ctx)); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/DeleteDataRequestAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/DeleteDataRequestAst.java new file mode 100644 index 000000000..6b016d7fa --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/DeleteDataRequestAst.java @@ -0,0 +1,21 @@ +package fr.inria.corese.core.next.query.impl.sparql.ast; + +import fr.inria.corese.core.next.query.impl.parser.semantic.support.AstVisitor; + +import java.util.List; + +/** + * Minimal AST representation of {@code DELETE DATA} for concrete triples. + */ +public record DeleteDataRequestAst(List triples) implements UpdateRequestUnitAst { + + public DeleteDataRequestAst { + triples = triples != null ? List.copyOf(triples) : List.of(); + } + + @Override + public void accept(AstVisitor visitor) { + visitor.visit(this); + triples.forEach(triple -> triple.accept(visitor)); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/InsertDataRequestAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/InsertDataRequestAst.java new file mode 100644 index 000000000..eacf7859c --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/InsertDataRequestAst.java @@ -0,0 +1,21 @@ +package fr.inria.corese.core.next.query.impl.sparql.ast; + +import fr.inria.corese.core.next.query.impl.parser.semantic.support.AstVisitor; + +import java.util.List; + +/** + * Minimal AST representation of {@code INSERT DATA} for concrete triples. + */ +public record InsertDataRequestAst(List triples) implements UpdateRequestUnitAst { + + public InsertDataRequestAst { + triples = triples != null ? List.copyOf(triples) : List.of(); + } + + @Override + public void accept(AstVisitor visitor) { + visitor.visit(this); + triples.forEach(triple -> triple.accept(visitor)); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/UpdateRequestUnitAst.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/UpdateRequestUnitAst.java index e68694223..9363e42cb 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/UpdateRequestUnitAst.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/ast/UpdateRequestUnitAst.java @@ -5,5 +5,5 @@ /** * Root interface for all operations related to the SPARQL Update operations listed in SPARQL 1.1 Update. */ -public sealed interface UpdateRequestUnitAst extends VisitableAst permits LoadRequestAst, ClearRequestAst, CreateRequestAst { +public sealed interface UpdateRequestUnitAst extends VisitableAst permits LoadRequestAst, ClearRequestAst, CreateRequestAst, InsertDataRequestAst, DeleteDataRequestAst { } 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 526f43f31..243d42671 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,8 +1,10 @@ package fr.inria.corese.core.next.query.impl.sparql.bridge; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; 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.ConstructQueryAst; 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; @@ -13,6 +15,7 @@ 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.TriplePatternAst; 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; @@ -101,6 +104,25 @@ public Query toNextQuery(SelectQueryAst selectQueryAst) { return query; } + /** + * Builds a KGRAM {@link Query} from a {@link ConstructQueryAst}. + */ + public Query toNextQuery(ConstructQueryAst constructQueryAst) { + Objects.requireNonNull(constructQueryAst, "constructQueryAst"); + rejectUnsupportedConstructClauses(constructQueryAst); + + Query query = createQuery( + constructQueryAst.whereClause(), + constructQueryAst.datasetClause(), + constructQueryAst.solutionModifier()); + applyOrderBy(query, constructQueryAst.solutionModifier()); + Exp constructTemplate = constructTemplate(constructQueryAst.constructTemplate().triplePatternAsts()); + query.setConstruct(constructTemplate); + query.setConstruct(true); + query.setConstructNodes(constructTemplate.getNodes()); + return query; + } + /** * Builds a KGRAM {@link Query} from a {@link DescribeQueryAst}. * @@ -173,12 +195,12 @@ static Node toNode(TermAst term) { private static void rejectUnsupportedAskClauses(AskQueryAst askQueryAst) { if (!askQueryAst.valuesClause().mappings().isEmpty()) { - throw new UnsupportedOperationException( + throw new UnsupportedQueryFeatureException( "Inline VALUES is not supported yet for ASK (values handling is a follow-up)"); } SolutionModifierAst mod = askQueryAst.solutionModifier(); if (mod.hasGroupBy() || mod.hasHaving() || mod.distinct() || mod.reduced()) { - throw new UnsupportedOperationException( + throw new UnsupportedQueryFeatureException( "Solution modifiers (GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for ASK"); } // TODO(#388): inline VALUES needs a dedicated runtime mapping. @@ -188,22 +210,22 @@ private static void rejectUnsupportedAskClauses(AskQueryAst askQueryAst) { private static void rejectUnsupportedSelectClauses(SelectQueryAst selectQueryAst) { if (!selectQueryAst.valuesClause().mappings().isEmpty()) { - throw new UnsupportedOperationException("VALUES is not supported yet when building a next Query"); + throw new UnsupportedQueryFeatureException("VALUES is not supported yet when building a next Query"); } ProjectionAst projection = selectQueryAst.projection(); if (!projection.expressionTerms().isEmpty() || !projection.expressionBoundVariables().isEmpty()) { - throw new UnsupportedOperationException( + throw new UnsupportedQueryFeatureException( "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"); + throw new UnsupportedQueryFeatureException("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"); + throw new UnsupportedQueryFeatureException("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"); + throw new UnsupportedQueryFeatureException("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. @@ -213,12 +235,12 @@ private static void rejectUnsupportedSelectClauses(SelectQueryAst selectQueryAst private static void rejectUnsupportedDescribeClauses(DescribeQueryAst describeQueryAst) { if (!describeQueryAst.valuesClause().mappings().isEmpty()) { - throw new UnsupportedOperationException( + throw new UnsupportedQueryFeatureException( "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( + throw new UnsupportedQueryFeatureException( "Solution modifiers (GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for DESCRIBE"); } // TODO(#390): inline VALUES needs a dedicated runtime mapping. @@ -226,6 +248,20 @@ private static void rejectUnsupportedDescribeClauses(DescribeQueryAst describeQu // TODO(#390): REDUCED support should be aligned with the final query-form policy for the next pipeline. } + private static void rejectUnsupportedConstructClauses(ConstructQueryAst constructQueryAst) { + if (!constructQueryAst.valuesClause().mappings().isEmpty()) { + throw new UnsupportedQueryFeatureException( + "Inline VALUES is not supported yet for CONSTRUCT (values handling is a follow-up)"); + } + SolutionModifierAst mod = constructQueryAst.solutionModifier(); + if (mod.hasGroupBy() || mod.hasHaving() || mod.distinct() || mod.reduced()) { + throw new UnsupportedQueryFeatureException( + "Solution modifiers (GROUP BY, HAVING, DISTINCT, REDUCED) are not supported for CONSTRUCT"); + } + // TODO(#473): blank nodes in CONSTRUCT templates need per-solution allocation. + // TODO(#473): inline VALUES needs a dedicated runtime mapping. + } + /** * Creates the runtime {@link Query} shell shared by every query form handled here. * @@ -268,6 +304,31 @@ private List toNodeList(Iterable iris) { return nodes; } + private Exp constructTemplate(List triples) { + Exp template = Exp.create(Type.BGP); + for (TriplePatternAst triple : triples) { + rejectUnsupportedConstructTemplateTerm(triple.subject(), "subject"); + rejectUnsupportedConstructTemplateTerm(triple.predicate(), "predicate"); + rejectUnsupportedConstructTemplateTerm(triple.object(), "object"); + template.add(Exp.create(Type.EDGE, new AstBackedEdge( + toNode(triple.subject()), + toNode(triple.predicate()), + toNode(triple.object())))); + } + return template; + } + + private static void rejectUnsupportedConstructTemplateTerm(TermAst term, String role) { + if (term instanceof IriAst(String raw) && isBlankNodeLabel(raw)) { + throw new UnsupportedQueryFeatureException( + "Blank nodes in CONSTRUCT " + role + " templates are not supported yet"); + } + } + + private static boolean isBlankNodeLabel(String raw) { + return raw.startsWith("_:") || raw.equals("[]") || raw.startsWith("["); + } + /** * Maps the {@code SELECT} projection onto the runtime query. * @@ -336,7 +397,9 @@ private void lowerDescribeToConstructQuery(Query query, List describedNode // 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())); + Exp optional = Exp.create(Type.OPTIONAL, Exp.create(Type.AND), describePattern.optionalBody()); + markEvaluable(optional); + query.getBody().add(optional); } query.setConstruct(constructTemplate); query.setConstruct(true); diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java new file mode 100644 index 000000000..420b02d04 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java @@ -0,0 +1,446 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Statement; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.api.ValueFactory; +import fr.inria.corese.core.next.data.impl.temp.CoreseAdaptedValueFactory; +import fr.inria.corese.core.next.query.impl.parser.SparqlParser; +import fr.inria.corese.core.next.query.impl.result.CoreseGraphQueryResult; +import fr.inria.corese.core.next.query.impl.result.CoreseTupleQueryResult; +import fr.inria.corese.core.next.query.impl.sparql.ast.AskQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ConstructQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.DeleteDataRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.DescribeQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.InsertDataRequestAst; +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.QueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ProjectionAsts; +import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UpdateRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UpdateRequestUnitAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; +import fr.inria.corese.core.next.query.impl.sparql.bridge.CoreseAstQueryBuilder; +import fr.inria.corese.core.next.query.kgram.api.core.Edge; +import fr.inria.corese.core.next.query.kgram.api.core.Node; +import fr.inria.corese.core.next.query.kgram.api.query.Environment; +import fr.inria.corese.core.next.query.kgram.api.query.Evaluator; +import fr.inria.corese.core.next.query.kgram.api.query.Matcher; +import fr.inria.corese.core.next.query.kgram.api.query.ProcessVisitor; +import fr.inria.corese.core.next.query.kgram.api.query.Producer; +import fr.inria.corese.core.next.query.kgram.core.Eval; +import fr.inria.corese.core.next.query.kgram.core.Mapping; +import fr.inria.corese.core.next.query.kgram.core.Mappings; +import fr.inria.corese.core.next.query.kgram.core.Query; +import fr.inria.corese.core.next.query.kgram.core.SparqlException; +import fr.inria.corese.core.next.query.kgram.tool.StorageManagerProducer; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storagemanager.api.StorageManager; +import fr.inria.corese.core.next.storagemanager.api.support.model.MutationResult; +import fr.inria.corese.core.next.storagemanager.api.support.model.StatementPattern; +import fr.inria.corese.core.sparql.api.IDatatype; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Internal integration executor for the autonomous next SPARQL pipeline. + * + *

This class is deliberately not the final public SPARQL API. It exists for + * #473 to exercise the complete next-only path from a SPARQL string to results:

+ * + *
+ * SPARQL string -> next parser -> next AST -> next KGRAM -> StorageManagerProducer -> StorageManager
+ * 
+ * + *

It is acceptable for this class to gather parsing, runtime creation, + * graph materialization, and minimal update execution while the autonomous path + * is being proven. Those responsibilities should be split into dedicated + * services before this becomes a stable user-facing query API.

+ * + *

UPDATE support is intentionally minimal for #473: only concrete {@code INSERT DATA} + * and {@code DELETE DATA} triples with explicit absolute IRIs are accepted. Unsupported + * SPARQL 1.1 forms fail explicitly instead of falling back to the legacy engine.

+ */ +public final class NextSparqlPipelineExecutor { + + private final StorageManager storage; + private final SparqlParser parser; + private final CoreseAstQueryBuilder queryBuilder; + private final ValueFactory valueFactory; + + public NextSparqlPipelineExecutor(StorageManager storage) { + this(storage, new SparqlParser(), new CoreseAstQueryBuilder(), new CoreseAdaptedValueFactory()); + } + + NextSparqlPipelineExecutor( + StorageManager storage, + SparqlParser parser, + CoreseAstQueryBuilder queryBuilder, + ValueFactory valueFactory) { + this.storage = Objects.requireNonNull(storage, "storage"); + this.parser = Objects.requireNonNull(parser, "parser"); + this.queryBuilder = Objects.requireNonNull(queryBuilder, "queryBuilder"); + this.valueFactory = Objects.requireNonNull(valueFactory, "valueFactory"); + } + + public TupleQueryResult evaluateTuple(String sparql) { + QueryAst ast = parser.parse(sparql); + if (!(ast instanceof SelectQueryAst select)) { + throw new UnsupportedQueryFeatureException("Expected SELECT query, got: " + ast.getClass().getSimpleName()); + } + return new CoreseTupleQueryResult(evaluate(queryBuilder.toNextQuery(select))); + } + + public boolean evaluateBoolean(String sparql) { + QueryAst ast = parser.parse(sparql); + if (!(ast instanceof AskQueryAst ask)) { + throw new UnsupportedQueryFeatureException("Expected ASK query, got: " + ast.getClass().getSimpleName()); + } + return evaluate(queryBuilder.toNextQuery(ask)).size() > 0; + } + + public GraphQueryResult evaluateGraph(String sparql) { + QueryAst ast = parser.parse(sparql); + return switch (ast) { + case ConstructQueryAst construct -> { + Query query = queryBuilder.toNextQuery(construct); + yield new CoreseGraphQueryResult(materialize(query, evaluate(query)).iterator()); + } + case DescribeQueryAst describe -> new CoreseGraphQueryResult(describe(describe).iterator()); + default -> throw new UnsupportedQueryFeatureException( + "Expected CONSTRUCT or DESCRIBE query, got: " + ast.getClass().getSimpleName()); + }; + } + + public void executeUpdate(String sparql) { + QueryAst ast = parser.parse(sparql); + if (!(ast instanceof UpdateRequestAst update)) { + throw new UnsupportedQueryFeatureException("Expected SPARQL UPDATE request, got: " + ast.getClass().getSimpleName()); + } + for (UpdateRequestUnitAst operation : update.operations()) { + executeOperation(operation); + } + } + + private Mappings evaluate(Query query) { + try { + Eval eval = Eval.create(new StorageManagerProducer(storage), new NoOpEvaluator(), new BasicMatcher()); + eval.setVisitor(new ProcessVisitor() { + }); + return eval.query(query); + } catch (SparqlException e) { + throw new QueryEvaluationException("Failed to evaluate query with the next pipeline: " + e.getMessage(), e); + } + } + + private List materialize(Query query, Mappings mappings) { + if (query.getConstruct() == null) { + throw new UnsupportedQueryFeatureException("Graph query has no construct template to materialize"); + } + List statements = new ArrayList<>(); + for (Mapping mapping : mappings) { + for (var template : query.getConstruct()) { + if (!template.isEdge()) { + throw new UnsupportedQueryFeatureException("Only EDGE expressions are supported in graph templates"); + } + Statement statement = statementFromTemplate(template.getEdge(), mapping); + if (statement != null && !statements.contains(statement)) { + statements.add(statement); + } + } + } + return statements; + } + + private List describe(DescribeQueryAst describe) { + SelectQueryAst select = new SelectQueryAst( + ProjectionAsts.selectAll(), + describe.datasetClause(), + describe.whereClause(), + describe.solutionModifier(), + describe.prologue(), + describe.valuesClause()); + Mappings mappings = evaluate(queryBuilder.toNextQuery(select)); + List resources = describedResources(describe, mappings); + List statements = new ArrayList<>(); + for (Resource resource : resources) { + try (var outgoing = storage.getQueryOperations().query(StatementPattern.of(resource, null, null))) { + outgoing.filter(statement -> !statements.contains(statement)).forEach(statements::add); + } + try (var incoming = storage.getQueryOperations().query(StatementPattern.of(null, null, resource))) { + incoming.filter(statement -> !statements.contains(statement)).forEach(statements::add); + } + } + return statements; + } + + private List describedResources(DescribeQueryAst describe, Mappings mappings) { + List resources = new ArrayList<>(); + for (Mapping mapping : mappings) { + if (describe.isDescribeAll()) { + for (String variable : mapping.getVariableNames()) { + addResource(resources, nullableValue(mapping.getNode(variable))); + } + } else { + for (TermAst term : describe.described()) { + if (term instanceof VarAst(String variableName)) { + addResource(resources, nullableValue(mapping.getNode(variableName))); + } else { + addResource(resources, termValue(term)); + } + } + } + } + return resources; + } + + private void addResource(List resources, Value value) { + if (value instanceof Resource resource && !resources.contains(resource)) { + resources.add(resource); + } + } + + private Statement statementFromTemplate(Edge edge, Mapping mapping) { + Value subjectValue = nullableValue(resolve(edge.getNode(0), mapping)); + Value predicateValue = nullableValue(resolve(predicateNode(edge), mapping)); + Value object = nullableValue(resolve(edge.getNode(1), mapping)); + if (subjectValue == null || predicateValue == null || object == null) { + return null; + } + Resource subject = asResource(subjectValue, "subject"); + IRI predicate = asIri(predicateValue, "predicate"); + return valueFactory.createStatement(subject, predicate, object); + } + + private Node resolve(Node node, Mapping mapping) { + if (node != null && node.isVariable()) { + return mapping.getNode(node); + } + return node; + } + + private void executeOperation(UpdateRequestUnitAst operation) { + switch (operation) { + case InsertDataRequestAst(List triples) -> { + for (TriplePatternAst triple : triples) { + checkMutation(storage.getMutationOperations().insertStatement(statementFromAst(triple))); + } + } + case DeleteDataRequestAst(List triples) -> { + for (TriplePatternAst triple : triples) { + checkMutation(storage.getMutationOperations().deleteStatement(statementFromAst(triple))); + } + } + default -> throw new UnsupportedQueryFeatureException( + "SPARQL update operation is not supported yet by the next pipeline: " + + operation.getClass().getSimpleName()); + } + } + + private Statement statementFromAst(TriplePatternAst triple) { + Resource subject = asResource(termValue(triple.subject()), "subject"); + IRI predicate = asIri(termValue(triple.predicate()), "predicate"); + Value object = termValue(triple.object()); + return valueFactory.createStatement(subject, predicate, object); + } + + private Value termValue(TermAst term) { + return switch (term) { + case VarAst(String variableName) -> throw new UnsupportedQueryFeatureException( + "Variables are not allowed in INSERT DATA / DELETE DATA: ?" + variableName); + case IriAst(String rawIri) -> valueFactory.createIRI(normalizeExplicitIri(rawIri, "IRI term")); + case LiteralAst(String lexical, String language, String datatype) -> { + String label = unquote(lexical); + if (language != null && !language.isBlank()) { + yield valueFactory.createLiteral(label, language); + } + if (datatype != null && !datatype.isBlank()) { + yield valueFactory.createLiteral(label, valueFactory.createIRI(normalizeDatatypeIri(datatype))); + } + yield valueFactory.createLiteral(label); + } + default -> throw new UnsupportedQueryFeatureException( + "Unsupported RDF term in update data: " + term.getClass().getSimpleName()); + }; + } + + private void checkMutation(MutationResult result) { + if (result.isFailure()) { + throw new QueryEvaluationException("Storage mutation failed: " + result.getMessage()); + } + } + + private Resource asResource(Value value, String role) { + if (value instanceof Resource resource) { + return resource; + } + throw new QueryEvaluationException("Constructed " + role + " is not an RDF resource"); + } + + private IRI asIri(Value value, String role) { + if (value instanceof IRI iri) { + return iri; + } + throw new QueryEvaluationException("Constructed " + role + " is not an IRI"); + } + + private Value nullableValue(Node node) { + if (node == null) { + return null; + } + IDatatype datatype = node.getDatatypeValue(); + if (datatype.isURI()) { + return valueFactory.createIRI(datatype.getLabel()); + } + if (datatype.isBlank()) { + return valueFactory.createBNode(datatype.getLabel()); + } + if (datatype.isLiteral()) { + if (datatype.getLang() != null && !datatype.getLang().isBlank()) { + return valueFactory.createLiteral(datatype.getLabel(), datatype.getLang()); + } + if (datatype.getDatatypeURI() != null && !datatype.getDatatypeURI().isBlank()) { + return valueFactory.createLiteral(datatype.getLabel(), valueFactory.createIRI(datatype.getDatatypeURI())); + } + return valueFactory.createLiteral(datatype.getLabel()); + } + throw new QueryEvaluationException("Unsupported KGRAM node datatype: " + datatype); + } + + private static Node predicateNode(Edge edge) { + return edge.getEdgeVariable() == null ? edge.getEdgeNode() : edge.getEdgeVariable(); + } + + private static String normalizeExplicitIri(String raw, String role) { + if (raw.startsWith("<") && raw.endsWith(">")) { + String iri = raw.substring(1, raw.length() - 1); + if (isAbsoluteIri(iri)) { + return iri; + } + throw new UnsupportedQueryFeatureException(role + " must be absolute, got: " + raw); + } + throw new UnsupportedQueryFeatureException( + role + " must be an explicit absolute IRI between <...>; prefixed names are not resolved here: " + raw); + } + + private static String normalizeDatatypeIri(String raw) { + if (raw.startsWith("<") && raw.endsWith(">")) { + String iri = raw.substring(1, raw.length() - 1); + if (isAbsoluteIri(iri)) { + return iri; + } + throw new UnsupportedQueryFeatureException("Literal datatype must be absolute, got: " + raw); + } + if (isAbsoluteIri(raw)) { + return raw; + } + throw new UnsupportedQueryFeatureException( + "Literal datatype must be an explicit absolute IRI; prefixed datatypes are not resolved here: " + raw); + } + + private static boolean isAbsoluteIri(String iri) { + return iri.startsWith("http://") || iri.startsWith("https://") || iri.startsWith("urn:"); + } + + private static String unquote(String lexical) { + if (lexical.length() >= 2 && lexical.startsWith("\"") && lexical.endsWith("\"")) { + String unquoted = lexical.substring(1, lexical.length() - 1); + if (unquoted.contains("\\")) { + throw new UnsupportedQueryFeatureException( + "Escaped string literals are not supported yet by the minimal next update executor"); + } + return unquoted; + } + if (lexical.contains("\\")) { + throw new UnsupportedQueryFeatureException( + "Escaped string literals are not supported yet by the minimal next update executor"); + } + return lexical; + } + + private static final class BasicMatcher implements Matcher { + private int mode = Matcher.UNDEF; + + @Override + public boolean match(Edge query, Edge target, Environment environment) { + return match(query.getNode(0), target.getNode(0), environment) + && match(query.getNode(1), target.getNode(1), environment) + && match(predicateNode(query), target.getEdgeNode(), environment); + } + + @Override + public boolean match(Node query, Node target, Environment environment) { + if (query == null || target == null) { + return query == target; + } + if (query.isVariable()) { + Node bound = environment == null ? null : environment.getNode(query); + return bound == null || bound.match(target); + } + return query.match(target); + } + + @Override + public boolean same(Node queryNode, Node left, Node right, Environment environment) { + return left != null && left.same(right); + } + + @Override + public int getMode() { + return mode; + } + + @Override + public void setMode(int mode) { + this.mode = mode; + } + } + + private static final class NoOpEvaluator implements Evaluator { + private Mode mode = Mode.KGRAM_MODE; + + @Override + public Mode getMode() { + return mode; + } + + @Override + public void setMode(Mode mode) { + this.mode = mode; + } + + @Override + public void setProducer(Producer producer) { + // No producer state is needed by this minimal evaluator. + } + + @Override + public void setKGRAM(Eval eval) { + // The executor drives Eval directly, so there is no evaluator-side KGRAM state to keep. + } + + @Override + public void start(Environment environment) { + // No per-query initialization is needed for the supported #473 query subset. + } + + @Override + public void finish(Environment environment) { + // No per-query cleanup is needed for the supported #473 query subset. + } + + @Override + public void init(Environment environment) { + // No per-environment initialization is needed by this no-op evaluator. + } + } +} 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 603320a88..7e838d10f 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 @@ -1,5 +1,6 @@ package fr.inria.corese.core.next.query.impl.sparql.bridge; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; 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.AskQueryAst; @@ -112,14 +113,14 @@ void appliesFromNamedClause() { } @Test - @DisplayName("Inline VALUES is not supported yet → UnsupportedOperationException") + @DisplayName("Inline VALUES is not supported yet -> UnsupportedQueryFeatureException") void rejectsValuesClause() { ValuesAst values = new ValuesAst(List.of( new ValueMappingAst(Map.of(new VarAst("s"), new IriAst("http://example.org/x"))))); AskQueryAst ask = new AskQueryAst( DatasetClauseAst.none(), whereOneTriple(), null, null, values); - assertThrows(UnsupportedOperationException.class, () -> builder.toNextQuery(ask)); + assertThrows(UnsupportedQueryFeatureException.class, () -> builder.toNextQuery(ask)); } @Test 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 1ec39ad97..bb3039ba0 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 @@ -1,5 +1,6 @@ package fr.inria.corese.core.next.query.impl.sparql.bridge; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; 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; @@ -142,25 +143,25 @@ void appliesOrderBy() { } @Test - @DisplayName("Inline VALUES is not supported yet → UnsupportedOperationException") + @DisplayName("Inline VALUES is not supported yet -> UnsupportedQueryFeatureException") 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)); + assertThrows(UnsupportedQueryFeatureException.class, () -> builder.toNextQuery(describe)); } @Test - @DisplayName("Unsupported solution modifier (e.g. DISTINCT) → UnsupportedOperationException") + @DisplayName("Unsupported solution modifier (e.g. DISTINCT) -> UnsupportedQueryFeatureException") 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)); + assertThrows(UnsupportedQueryFeatureException.class, () -> builder.toNextQuery(describe)); } @Test diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java new file mode 100644 index 000000000..73855d1f7 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java @@ -0,0 +1,224 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Statement; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.api.ValueFactory; +import fr.inria.corese.core.next.data.impl.temp.CoreseAdaptedValueFactory; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storagemanager.api.support.model.StatementPattern; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NextSparqlPipelineExecutorTest { + + private static final String ALICE = "http://example.org/alice"; + private static final String BOB = "http://example.org/bob"; + private static final String KNOWS = "http://example.org/knows"; + + private ValueFactory valueFactory; + private MemoryStorageManager storage; + private NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + valueFactory = new CoreseAdaptedValueFactory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + + insert(iri(ALICE), iri(KNOWS), iri(BOB)); + } + + @Test + @DisplayName("SELECT * WHERE { ?s ?p ?o } runs through the next pipeline") + void selectSpoRunsEndToEnd() { + TupleQueryResult result = executor.evaluateTuple("SELECT * WHERE { ?s ?p ?o }"); + + assertEquals(List.of("s", "p", "o"), result.getBindingNames()); + assertTrue(result.hasNext()); + var binding = result.next(); + assertEquals(ALICE, binding.getValue("s").stringValue()); + assertEquals(KNOWS, binding.getValue("p").stringValue()); + assertEquals(BOB, binding.getValue("o").stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("ASK WHERE { ?s ?p ?o } returns true when data exists") + void askSpoReturnsTrueWhenDataExists() { + assertTrue(executor.evaluateBoolean("ASK WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("ASK WHERE {

} returns false") + void askSpoReturnsFalseWhenNoDataExists() { + assertFalse(executor.evaluateBoolean(""" + ASK WHERE { + + } + """)); + } + + @Test + @DisplayName("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } materializes next statements") + void constructSpoRunsEndToEnd() { + GraphQueryResult result = executor.evaluateGraph("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"); + + List statements = result.asList(); + assertEquals(1, statements.size()); + assertEquals(ALICE, statements.getFirst().getSubject().stringValue()); + assertEquals(KNOWS, statements.getFirst().getPredicate().stringValue()); + assertEquals(BOB, statements.getFirst().getObject().stringValue()); + } + + @Test + @DisplayName("CONSTRUCT templates with blank nodes fail explicitly") + void constructBlankNodeTemplateFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.evaluateGraph(""" + CONSTRUCT { _:b ?o } + WHERE { ?s ?o } + """)); + + assertTrue(error.getMessage().contains("Blank nodes in CONSTRUCT")); + } + + @Test + @DisplayName("DESCRIBE ?s WHERE { ?s ?p ?o } follows the minimal outgoing/incoming strategy") + void describeSpoRunsEndToEnd() { + GraphQueryResult result = executor.evaluateGraph("DESCRIBE ?s WHERE { ?s ?p ?o }"); + + List statements = result.asList(); + assertEquals(1, statements.size()); + assertEquals(ALICE, statements.getFirst().getSubject().stringValue()); + assertEquals(KNOWS, statements.getFirst().getPredicate().stringValue()); + assertEquals(BOB, statements.getFirst().getObject().stringValue()); + } + + @Test + @DisplayName("INSERT DATA writes through StorageManager") + void insertDataWritesThroughStorageManager() { + executor.executeUpdate(""" + INSERT DATA { + + } + """); + + assertTrue(storage.getQueryOperations().exists(StatementPattern.of(iri(BOB), iri(KNOWS), iri(ALICE)))); + } + + @Test + @DisplayName("INSERT DATA with prefixed names fails explicitly") + void insertDataPrefixedNameFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate(""" + PREFIX ex: + INSERT DATA { ex:bob ex:knows ex:alice } + """)); + + assertTrue(error.getMessage().contains("prefixed names are not resolved here")); + } + + @Test + @DisplayName("INSERT DATA with relative IRIs fails explicitly") + void insertDataRelativeIriFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate(""" + INSERT DATA { + + } + """)); + + assertTrue(error.getMessage().contains("must be absolute")); + } + + @Test + @DisplayName("INSERT DATA with prefixed literal datatype fails explicitly") + void insertDataPrefixedDatatypeFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate(""" + PREFIX xsd: + INSERT DATA { + "42"^^xsd:integer + } + """)); + + assertTrue(error.getMessage().contains("prefixed datatypes are not resolved here")); + } + + @Test + @DisplayName("INSERT DATA with escaped string literal fails explicitly") + void insertDataEscapedLiteralFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate(""" + INSERT DATA { + "Bo\\nb" + } + """)); + + assertTrue(error.getMessage().contains("Escaped string literals are not supported yet")); + } + + @Test + @DisplayName("INSERT DATA with GRAPH blocks fails explicitly") + void insertDataGraphBlockFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate(""" + INSERT DATA { + GRAPH { + + } + } + """)); + + assertTrue(error.getMessage().contains("GRAPH blocks")); + } + + @Test + @DisplayName("DELETE DATA deletes through StorageManager") + void deleteDataDeletesThroughStorageManager() { + executor.executeUpdate(""" + DELETE DATA { + + } + """); + + assertFalse(storage.getQueryOperations().exists(StatementPattern.of(iri(ALICE), iri(KNOWS), iri(BOB)))); + } + + @Test + @DisplayName("Unsupported update operations fail explicitly") + void unsupportedUpdateOperationFailsExplicitly() { + UnsupportedQueryFeatureException error = assertThrows( + UnsupportedQueryFeatureException.class, + () -> executor.executeUpdate("CLEAR DEFAULT")); + + assertTrue(error.getMessage().contains("not supported yet")); + } + + private void insert(Resource subject, IRI predicate, Value object) { + storage.getMutationOperations().insertStatement(valueFactory.createStatement(subject, predicate, object)); + } + + private IRI iri(String iri) { + return valueFactory.createIRI(iri); + } +}